Nodejs進階:如何玩轉子程式(child_process)

程式猿小卡_casper發表於2016-12-08

本文摘錄自《Nodejs學習筆記》,更多章節及更新,請訪問 github主頁地址。歡迎加群交流,群號 197339705

模組概覽

在node中,child_process這個模組非常重要。掌握了它,等於在node的世界開啟了一扇新的大門。熟悉shell指令碼的同學,可以用它來完成很多有意思的事情,比如檔案壓縮、增量部署等,感興趣的同學,看文字文後可以嘗試下。

舉個簡單的例子:

const spawn = require('child_process').spawn;
const ls = spawn('ls', ['-lh', '/usr']);

ls.stdout.on('data', (data) => {
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data', (data) => {
  console.log(`stderr: ${data}`);
});

ls.on('close', (code) => {
  console.log(`child process exited with code ${code}`);
});複製程式碼

幾種建立子程式的方式

注意事項:

  • 下面列出來的都是非同步建立子程式的方式,每一種方式都有對應的同步版本。
  • .exec().execFile().fork()底層都是透過.spawn()實現的。
  • .exec()execFile()額外提供了回撥,當子程式停止的時候執行。

child_process.spawn(command[, args][, options])
child_process.exec(command[, options][, callback])
child_process.execFile(file[, args][, options][, callback])
child_process.fork(modulePath[, args][, options])

child_process.exec(command[, options][, callback])

建立一個shell,然後在shell裡執行命令。執行完成後,將stdout、stderr作為引數傳入回撥方法。

spawns a shell and runs a command within that shell, passing the stdout and stderr to a callback function when complete.

例子如下:

  1. 執行成功,errornull;執行失敗,errorError例項。error.code為錯誤碼,
  2. stdoutstderr為標準輸出、標準錯誤。預設是字串,除非options.encodingbuffer
var exec = require('child_process').exec;

// 成功的例子
exec('ls -al', function(error, stdout, stderr){
    if(error) {
        console.error('error: ' + error);
        return;
    }
    console.log('stdout: ' + stdout);
    console.log('stderr: ' + typeof stderr);
});

// 失敗的例子
exec('ls hello.txt', function(error, stdout, stderr){
    if(error) {
        console.error('error: ' + error);
        return;
    }
    console.log('stdout: ' + stdout);
    console.log('stderr: ' + stderr);
});複製程式碼

引數說明:

  • cwd:當前工作路徑。
  • env:環境變數。
  • encoding:編碼,預設是utf8
  • shell:用來執行命令的shell,unix上預設是/bin/sh,windows上預設是cmd.exe
  • timeout:預設是0。
  • killSignal:預設是SIGTERM
  • uid:執行程式的uid。
  • gid:執行程式的gid。
  • maxBuffer 標準輸出、錯誤輸出最大允許的資料量(單位為位元組),如果超出的話,子程式就會被殺死。預設是200*1024(就是200k啦)

備註:

  1. 如果timeout大於0,那麼,當子程式執行超過timeout毫秒,那麼,就會給程式傳送killSignal指定的訊號(比如SIGTERM)。
  2. 如果執行沒有出錯,那麼errornull。如果執行出錯,那麼,error.code就是退出程式碼(exist code),error.signal會被設定成終止程式的訊號。(比如CTRL+C時傳送的SIGINT

風險項

傳入的命令,如果是使用者輸入的,有可能產生類似sql注入的風險,比如

exec('ls hello.txt; rm -rf *', function(error, stdout, stderr){
    if(error) {
        console.error('error: ' + error);
        // return;
    }
    console.log('stdout: ' + stdout);
    console.log('stderr: ' + stderr);
});複製程式碼

備註事項

Note: Unlike the exec(3) POSIX system call, child_process.exec() does not replace the existing process and uses a shell to execute the command.

child_process.execFile(file[, args][, options][, callback])

.exec()類似,不同點在於,沒有建立一個新的shell。至少有兩點影響

  1. child_process.exec()效率高一些。(實際待測試)
  2. 一些操作,比如I/O重定向,檔案glob等不支援。

similar to child_process.exec() except that it spawns the command directly without first spawning a shell.

file 可執行檔案的名字,或者路徑。

例子:

var child_process = require('child_process');

child_process.execFile('node', ['--version'], function(error, stdout, stderr){
    if(error){
        throw error;
    }
    console.log(stdout);
});

child_process.execFile('/Users/a/.nvm/versions/node/v6.1.0/bin/node', ['--version'], function(error, stdout, stderr){
    if(error){
        throw error;
    }
    console.log(stdout);
});複製程式碼

====== 擴充套件閱讀 =======

從node原始碼來看,exec()execFile()最大的差別,就在於是否建立了shell。(execFile()內部,options.shell === false),那麼,可以手動設定shell。以下程式碼差不多是等價的。win下的shell設定有所不同,感興趣的同學可以自己試驗下。

備註:execFile()內部最終還是透過spawn()實現的, 如果沒有設定 {shell: '/bin/bash'},那麼 spawm() 內部對命令的解析會有所不同,execFile('ls -al .') 會直接報錯。

var child_process = require('child_process');
var execFile = child_process.execFile;
var exec = child_process.exec;

exec('ls -al .', function(error, stdout, stderr){
    if(error){
        throw error;
    }
    console.log(stdout);
});

execFile('ls -al .', {shell: '/bin/bash'}, function(error, stdout, stderr){
    if(error){
        throw error;
    }
    console.log(stdout);
});複製程式碼

child_process.fork(modulePath[, args][, options])

modulePath:子程式執行的模組。

引數說明:(重複的引數說明就不在這裡列舉)

  • execPath 用來建立子程式的可執行檔案,預設是/usr/local/bin/node。也就是說,你可透過execPath來指定具體的node可執行檔案路徑。(比如多個node版本)
  • execArgv 傳給可執行檔案的字串引數列表。預設是process.execArgv,跟父程式保持一致。
  • silent 預設是false,即子程式的stdio從父程式繼承。如果是true,則直接pipe向子程式的child.stdinchild.stdout等。
  • stdio 如果宣告瞭stdio,則會覆蓋silent選項的設定。

例子1:silent

parent.js

var child_process = require('child_process');

// 例子一:會列印出 output from the child
// 預設情況,silent 為 false,子程式的 stdout 等
// 從父程式繼承
child_process.fork('./child.js', {
    silent: false
});

// 例子二:不會列印出 output from the silent child
// silent 為 true,子程式的 stdout 等
// pipe 向父程式
child_process.fork('./silentChild.js', {
    silent: true
});

// 例子三:列印出 output from another silent child
var child = child_process.fork('./anotherSilentChild.js', {
    silent: true
});

child.stdout.setEncoding('utf8');
child.stdout.on('data', function(data){
    console.log(data);
});複製程式碼

child.js

console.log('output from the child');複製程式碼

silentChild.js

console.log('output from the silent child');複製程式碼

anotherSilentChild.js

console.log('output from another silent child');複製程式碼

例子二:ipc

parent.js

var child_process = require('child_process');

var child = child_process.fork('./child.js');

child.on('message', function(m){
    console.log('message from child: ' + JSON.stringify(m));
});

child.send({from: 'parent'});複製程式碼
process.on('message', function(m){
    console.log('message from parent: ' + JSON.stringify(m));
});

process.send({from: 'child'});複製程式碼

執行結果

➜  ipc git:(master) ✗ node parent.js
message from child: {"from":"child"}
message from parent: {"from":"parent"}複製程式碼

例子三:execArgv

首先,process.execArgv的定義,參考這裡。設定execArgv的目的一般在於,讓子程式跟父程式保持相同的執行環境。

比如,父程式指定了--harmony,如果子程式沒有指定,那麼就要跪了。

parent.js

var child_process = require('child_process');

console.log('parent execArgv: ' + process.execArgv);

child_process.fork('./child.js', {
    execArgv: process.execArgv
});複製程式碼

child.js

console.log('child execArgv: ' + process.execArgv);複製程式碼

執行結果

➜  execArgv git:(master) ✗ node --harmony parent.js
parent execArgv: --harmony
child execArgv: --harmony複製程式碼

例子3:execPath(TODO 待舉例子)

child_process.spawn(command[, args][, options])

command:要執行的命令

options引數說明:

  • argv0:[String] 這貨比較詭異,在uninx、windows上表現不一樣。有需要再深究。
  • stdio:[Array] | [String] 子程式的stdio。參考這裡
  • detached:[Boolean] 讓子程式獨立於父程式之外執行。同樣在不同平臺上表現有差異,具體參考這裡
  • shell:[Boolean] | [String] 如果是true,在shell裡執行程式。預設是false。(很有用,比如 可以透過 /bin/sh -c xxx 來實現 .exec() 這樣的效果)

例子1:基礎例子

var spawn = require('child_process').spawn;
var ls = spawn('ls', ['-al']);

ls.stdout.on('data', function(data){
    console.log('data from child: ' + data);
});


ls.stderr.on('data', function(data){
    console.log('error from child: ' + data);
});

ls.on('close', function(code){
    console.log('child exists with code: ' + code);
});複製程式碼

例子2:宣告stdio

var spawn = require('child_process').spawn;
var ls = spawn('ls', ['-al'], {
    stdio: 'inherit'
});

ls.on('close', function(code){
    console.log('child exists with code: ' + code);
});複製程式碼

例子3:宣告使用shell

var spawn = require('child_process').spawn;

// 執行 echo "hello nodejs" | wc
var ls = spawn('bash', ['-c', 'echo "hello nodejs" | wc'], {
    stdio: 'inherit',
    shell: true
});

ls.on('close', function(code){
    console.log('child exists with code: ' + code);
});複製程式碼

例子4:錯誤處理,包含兩種場景,這兩種場景有不同的處理方式。

  • 場景1:命令本身不存在,建立子程式報錯。
  • 場景2:命令存在,但執行過程報錯。
var spawn = require('child_process').spawn;
var child = spawn('bad_command');

child.on('error', (err) => {
  console.log('Failed to start child process 1.');
});

var child2 = spawn('ls', ['nonexistFile']);

child2.stderr.on('data', function(data){
    console.log('Error msg from process 2: ' + data);
});

child2.on('error', (err) => {
  console.log('Failed to start child process 2.');
});複製程式碼

執行結果如下。

➜  spawn git:(master) ✗ node error/error.js
Failed to start child process 1.
Error msg from process 2: ls: nonexistFile: No such file or directory複製程式碼

例子5:echo "hello nodejs" | grep "nodejs"

// echo "hello nodejs" | grep "nodejs"
var child_process = require('child_process');

var echo = child_process.spawn('echo', ['hello nodejs']);
var grep = child_process.spawn('grep', ['nodejs']);

grep.stdout.setEncoding('utf8');

echo.stdout.on('data', function(data){
    grep.stdin.write(data);
});

echo.on('close', function(code){
    if(code!==0){
        console.log('echo exists with code: ' + code);
    }
    grep.stdin.end();
});

grep.stdout.on('data', function(data){
    console.log('grep: ' + data);
});

grep.on('close', function(code){
    if(code!==0){
        console.log('grep exists with code: ' + code);
    }
});複製程式碼

執行結果:

➜  spawn git:(master) ✗ node pipe/pipe.js
grep: hello nodejs複製程式碼

關於options.stdio

預設值:['pipe', 'pipe', 'pipe'],這意味著:

  1. child.stdin、child.stdout 不是undefined
  2. 可以透過監聽 data 事件,來獲取資料。

基礎例子

var spawn = require('child_process').spawn;
var ls = spawn('ls', ['-al']);

ls.stdout.on('data', function(data){
    console.log('data from child: ' + data);
});

ls.on('close', function(code){
    console.log('child exists with code: ' + code);
});複製程式碼

透過child.stdin.write()寫入

var spawn = require('child_process').spawn;
var grep = spawn('grep', ['nodejs']);

setTimeout(function(){
    grep.stdin.write('hello nodejs \n hello javascript');
    grep.stdin.end();
}, 2000);

grep.stdout.on('data', function(data){
    console.log('data from grep: ' + data);
});

grep.on('close', function(code){
    console.log('grep exists with code: ' + code);
});複製程式碼

非同步 vs 同步

大部分時候,子程式的建立是非同步的。也就是說,它不會阻塞當前的事件迴圈,這對於效能的提升很有幫助。

當然,有的時候,同步的方式會更方便(阻塞事件迴圈),比如透過子程式的方式來執行shell指令碼時。

node同樣提供同步的版本,比如:

  • spawnSync()
  • execSync()
  • execFileSync()

關於options.detached

由於木有在windows上做測試,於是先貼原文

On Windows, setting options.detached to true makes it possible for the child process to continue running after the parent exits. The child will have its own console window. Once enabled for a child process, it cannot be disabled.

在非window是平臺上的表現

On non-Windows platforms, if options.detached is set to true, the child process will be made the leader of a new process group and session. Note that child processes may continue running after the parent exits regardless of whether they are detached or not. See setsid(2) for more information.

預設情況:父程式等待子程式結束。

子程式。可以看到,有個定時器一直在跑

var times = 0;
setInterval(function(){
    console.log(++times);
}, 1000);複製程式碼

執行下面程式碼,會發現父程式一直hold著不退出。

var child_process = require('child_process');
child_process.spawn('node', ['child.js'], {
    // stdio: 'inherit'
});複製程式碼

透過child.unref()讓父程式退出

呼叫child.unref(),將子程式從父程式的事件迴圈中剔除。於是父程式可以愉快的退出。這裡有幾個要點

  1. 呼叫child.unref()
  2. 設定detachedtrue
  3. 設定stdioignore(這點容易忘)
var child_process = require('child_process');
var child = child_process.spawn('node', ['child.js'], {
    detached: true,
    stdio: 'ignore'  // 備註:如果不置為 ignore,那麼 父程式還是不會退出
    // stdio: 'inherit'
});

child.unref();複製程式碼

stdio重定向到檔案

除了直接將stdio設定為ignore,還可以將它重定向到本地的檔案。

var child_process = require('child_process');
var fs = require('fs');

var out = fs.openSync('./out.log', 'a');
var err = fs.openSync('./err.log', 'a');

var child = child_process.spawn('node', ['child.js'], {
    detached: true,
    stdio: ['ignore', out, err]
});

child.unref();複製程式碼

exec()與execFile()之間的區別

首先,exec() 內部呼叫 execFile() 來實現,而 execFile() 內部呼叫 spawn() 來實現。

exec() -> execFile() -> spawn()

其次,execFile() 內部預設將 options.shell 設定為false,exec() 預設不是false。

Class: ChildProcess

  • 透過child_process.spawn()等建立,一般不直接用建構函式建立。
  • 繼承了EventEmitters,所以有.on()等方法。

各種事件

close

當stdio流關閉時觸發。這個事件跟exit不同,因為多個程式可以共享同個stdio流。
引數:code(退出碼,如果子程式是自己退出的話),signal(結束子程式的訊號)
問題:code一定是有的嗎?(從對code的註解來看好像不是)比如用kill殺死子程式,那麼,code是?

exit

引數:code、signal,如果子程式是自己退出的,那麼code就是退出碼,否則為null;如果子程式是透過訊號結束的,那麼,signal就是結束程式的訊號,否則為null。這兩者中,一者肯定不為null。
注意事項:exit事件觸發時,子程式的stdio stream可能還開啟著。(場景?)此外,nodejs監聽了SIGINT和SIGTERM訊號,也就是說,nodejs收到這兩個訊號時,不會立刻退出,而是先做一些清理的工作,然後重新丟擲這兩個訊號。(目測此時js可以做清理工作了,比如關閉資料庫等。)

SIGINT:interrupt,程式終止訊號,通常在使用者按下CTRL+C時發出,用來通知前臺程式終止程式。
SIGTERM:terminate,程式結束訊號,該訊號可以被阻塞和處理,通常用來要求程式自己正常退出。shell命令kill預設產生這個訊號。如果訊號終止不了,我們才會嘗試SIGKILL(強制終止)。

Also, note that Node.js establishes signal handlers for SIGINT and SIGTERM and Node.js processes will not terminate immediately due to receipt of those signals. Rather, Node.js will perform a sequence of cleanup actions and then will re-raise the handled signal.

error

當發生下列事情時,error就會被觸發。當error觸發時,exit可能觸發,也可能不觸發。(內心是崩潰的)

  • 無法建立子程式。
  • 程式無法kill。(TODO 舉例子)
  • 向子程式傳送訊息失敗。(TODO 舉例子)

message

當採用process.send()來傳送訊息時觸發。
引數:message,為json物件,或者primitive value;sendHandle,net.Socket物件,或者net.Server物件(熟悉cluster的同學應該對這個不陌生)

.connected:當呼叫.disconnected()時,設為false。代表是否能夠從子程式接收訊息,或者對子程式傳送訊息。

.disconnect():關閉父程式、子程式之間的IPC通道。當這個方法被呼叫時,disconnect事件就會觸發。如果子程式是node例項(透過child_process.fork()建立),那麼在子程式內部也可以主動呼叫process.disconnect()來終止IPC通道。參考process.disconnect

非重要的備忘點

windows平臺上的cmdbat

The importance of the distinction between child_process.exec() and child_process.execFile() can vary based on platform. On Unix-type operating systems (Unix, Linux, OSX) child_process.execFile() can be more efficient because it does not spawn a shell. On Windows, however, .bat and .cmd files are not executable on their own without a terminal, and therefore cannot be launched using child_process.execFile(). When running on Windows, .bat and .cmd files can be invoked using child_process.spawn() with the shell option set, with child_process.exec(), or by spawning cmd.exe and passing the .bat or .cmd file as an argument (which is what the shell option and child_process.exec() do).

// On Windows Only ...
const spawn = require('child_process').spawn;
const bat = spawn('cmd.exe', ['/c', 'my.bat']);

bat.stdout.on('data', (data) => {
  console.log(data);
});

bat.stderr.on('data', (data) => {
  console.log(data);
});

bat.on('exit', (code) => {
  console.log(`Child exited with code ${code}`);
});

// OR...
const exec = require('child_process').exec;
exec('my.bat', (err, stdout, stderr) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(stdout);
});複製程式碼

程式標題

Note: Certain platforms (OS X, Linux) will use the value of argv[0] for the process title while others (Windows, SunOS) will use command.

Note: Node.js currently overwrites argv[0] with process.execPath on startup, so process.argv[0] in a Node.js child process will not match the argv0 parameter passed to spawn from the parent, retrieve it with the process.argv0 property instead.

程式碼執行次序的問題

p.js

const cp = require('child_process');
const n = cp.fork(`${__dirname}/sub.js`);

console.log('1');

n.on('message', (m) => {
  console.log('PARENT got message:', m);
});

console.log('2');

n.send({ hello: 'world' });

console.log('3');複製程式碼

sub.js

console.log('4');
process.on('message', (m) => {
  console.log('CHILD got message:', m);
});

process.send({ foo: 'bar' });
console.log('5');複製程式碼

執行node p.js,列印出來的內容如下

➜  ch node p.js       
1
2
3
4
5
PARENT got message: { foo: 'bar' }
CHILD got message: { hello: 'world' }複製程式碼

再來個例子

// p2.js
var fork = require('child_process').fork;

console.log('p: 1');

fork('./c2.js');

console.log('p: 2');

// 從測試結果來看,同樣是70ms,有的時候,定時器回撥比子程式先執行,有的時候比子程式慢執行。
const t = 70;
setTimeout(function(){
    console.log('p: 3 in %s', t);
}, t);


// c2.js
console.log('c: 1');複製程式碼

關於NODE_CHANNEL_FD

child_process.fork()時,如果指定了execPath,那麼父、子程式間透過NODE_CHANNEL_FD 進行通訊。

Node.js processes launched with a custom execPath will communicate with the parent process using the file descriptor (fd) identified using the environment variable NODE_CHANNEL_FD on the child process. The input and output on this fd is expected to be line delimited JSON objects.

寫在後面

內容較多,如有錯漏及建議請指出。

相關連結

官方檔案:nodejs.org/api/child_p…

相關文章