利用NODE整合外部應用程序
執行外部應用程序
- execFile:執行外部程序,并且需要提供一組參數,以及一個在進程退出后的緩沖輸出的回調。
- spawn:執行外部程序,并且需要提供一組參數,以及一個在進程退出后的輸入輸出和時間的數據流接口。
- exec:在一個命令行窗口中執行一個或多個命令,以及一個在進程退出后緩沖輸出的回調。
- fork:在一個獨立的進程中執行一個Node模塊,并且需要提供一組參數,以及一個類似spawn方法里的數據流和事件式的接口,同時設置好父進程和子進程之間的進程間通信。
execFile
這是一個非常通用的方法,運行一個外部程序并且得到相應的輸出結果。該方法是一個異步方法,在外部應用的輸出內部使用buffer存放起來直到外部應用退出時,回調被調用傳入對應的輸出數據。
var cp = require('child_process')
cp.execFile('echo', ['hello', 'world'],
function(err, stdout, stderr) {
if (err) console.error(err);
console.log('stdout', stdout);
console.log('stderr', stderr);
});
Windows/UNIX操作系統中都有一個PATH的環境變量,PATH包含了一組可執行程序的執行目錄列表。在Node中,當后臺運行execvp時,當沒有提供絕對或者相對路徑時,它會基于PATH里定義的路徑搜索所有相關的程序。
如果想要快速檢查PATH路徑包含哪一些目錄,可以在Node的交互式命令解析器里輸入:
$ node
> console.log(process.env.PATH.split(':').join('\n'))
/usr/local/bin
/usr/bin/bin
...
當然你可以繼續向PATH路徑當中添加你的路徑,但是必須保證這個設置是在你執行execFile之前。
process.env.PATH += ':/a/new/path/to/executable';
執行外部程序時出現的異常
主要的異常分為兩種,一種是提供的路徑或文件名稱不存在,一種是提供的應用路徑被鎖定(執行應用的權限不足)。
- 提供的路徑或文件名不存在時:通常會報錯ENOENT
- 執行應用的權限不足:通常會報錯EACCESS或者EPERM
- 該程序不能在當前的平臺下執行時:外部程序退出返回的狀態碼非零(即err.code!=0)
spawn
對于調用一個你可能預期有大數據量輸出的外部應用,流確實是一個很好的選擇。當外部程序輸出的數據可用時,此時你可以選擇馬上將數據輸出到內部應用,通過流。相反,而不是等到將所有數據緩存好之后再將其輸出。
var cp = require('child_process');
var child = cp.spawn('echo', ['hello', 'world']);
child.on('error', console.error);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
實例化一個spawn后,會返回一個ChildProccess對象,該對象中包含了stdin、stdout和stderr流對象,并且因為其流的性質,可以很好的進行無縫的處理。
var cp = require('child_process');
var cat = cp.spawn('cat', ['messy.txt']);
var sort = cp.spawn('sort');
var uniq = cp.spawn('uniq');
cat.stdout.pipe(sort.stdin);
sort.stdout.pipe(uniq.stdin);
uniq.stdout.pipe(process.stdout);
exec
如果需要在命令解析器里執行命令,你可以選擇使用exec。exec方法實際上也是調用/bin/sh(在UNIX/Linux下)或者cmd.exe(在Windows下)來執行命令的。當然,這種方法可行的前提是,你必須擁有其他需要被執行的命令的權限(如管道、重定向和后臺命令)。
cp.exec('cat messy.txt | sort | uniq',
function(err, stdout, stderr) {
console.log(stdout);
})
分離子進程
-
形式上的分離
當在進程里開啟該進程的子進程以后,子進程會依賴于父進程。當父進程關閉的時候,子進程也會隨著關閉,并且子進程是沒有自己的獨立I/O。那么如果想讓子進程脫離父進程,就需要使用spawn方法,從而使得子進程擁有和父進程一樣的級別,即成為一個進程組的頭。
var child = cp.spawn('./', [], {detached: true});
但是此時,子進程和父進程之間還是通過I/O互相連接的,所以如果不強制性終結正在運行的Node程序,就會發現父進程會一直保持活躍狀態,直到子進程結束。但是強制性終結Node程序以后,longrun會繼續執行,直到它自己終結。
-
I/O分離
而stdio選項就是來控制子進程的I/O連接到一個具體的地方,
stdio:['pipe', 'pipe', 'pipe']
三個流分別對應child.stdin、child.stdout和child.stderr。默認這些流都是開放的,所以父進程能夠與子進程之間進行通信。當然你可以使用很暴力的方式關閉掉這些流,阻止父進程與子進程進行通信。child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy();
但是既然我們根本不需要這些流,那就應該在源頭上去放棄掉這些流或者重新賦值將I/O指向別的地方。
var fs = require('fs'); var cp = require('child_process'); var outFd = fs.openSync('./longrun.out', 'a'); var errFd = fs.openSync('./longrun.err', 'a'); var child = cp.spawn('./longrun', [], { detached: true, stdio: ['ignore', outFd, errFd] });
ignore關鍵詞就是用來放棄相對應的流。
-
引用分離
盡管子進程被分離了并且它和父進程的I/O也被中斷了,但是父進程仍然會有一個堆子進程的內部引用,并且只要子進程沒有終結且這個引用沒有被移除,父進程都不會終結。所以可以通過child.unref()方法告訴Node不要將子進程的引用進行計數。下面的代碼就會再子進程執行spawn方法之后退出。
var fs = require('fs'); var cp = require('child_process'); var outFd = fs.openSync('./longrun.out', 'a'); var errFd = fs.openSync('./longrun.err', 'a'); var child = cp.spawn('./longrun', [], { detached: true, stdio: ['ignore', outFd, errFd] }); child.unref();
fork(操作一個獨立的Node進程)
var cp = require('child_process');
var child = cp.fork('./myChild');
默認情況下,通過fork創建的子進程所有的輸入輸出都是繼承自父進程的,并不會有child.stdin、child.stdout或child.stderr。
如果想提供像spawn一樣的默認的I/O配置,那么可以使用slient選項。
var cp = require('child_process');
var child = cp.fork('./myChild', { silent: true });
使用fork方法會開放一個IPC通道,使得不同的Node進程之間進行消息傳送。而Node進程之間主要是使用Event進行通信,在子進程這邊會暴露process.on('message')和process.send()來接收和發送消息,在父進程這邊使用child.on('message')和child.send()。
var cp = require('child_process');
var child = cp.fork('./child');
child.on('message', function(msg) {
console.log('got a message from child', msg);
});
child.send('sending a string');
因為我們打開了一個父進程和子進程間的一個IPC通道,只要子進程不中斷,父進程也就會保持活動狀態。如果需要中斷IPC通信連接,可以在父進程中顯式的實現:child.disconnect();
一個較好的父進程例子,考慮了多次調用以及子進程出現問題的情況:
function doWork(job, cb) {
var child = cp.fork('./worker');
var cbTriggered = false;
child
.once('error', function(err) {
if (!cbTriggered) {
cb(err);
cbTriggered = true;
}
// 子進程出現了異常則殺死子進程
child.kill();
})
.once('exit', function(code, signal) {
if (!cbTriggered)
cb(new Error('Child exited with code: ' + code));
})
.once('message', function(result) {
cb(null, result);
cbTriggered = true;
})
.send(job);
}
工作池
在Node的官方文檔中有述:
這些子節點仍然是一個V8的新實例。預計每一個節點需要耗時30毫秒的啟動時間和10MB的內存。
也就是說,你不能創建太多了,因為這些并不是沒有代價開銷的。
所以說在實現的過程當中,與其使用多個短時間的子進程,還不如維護一個工作池,池中存放了一些可以長時間運行的進程。
那么我們就在上面doWork的基礎之上,做一些優化,完成我們在工作池上的一個作業分配以及發送。
var cp = require('child_process');
var cpus = require('os').cpus().length;
module.exports = function(workModule) {
// 等待的作業
var awaiting = [];
// 空閑的子進程
var readyPool = [];
// 總子進程的個數
var poolSize = 0;
return function doWork(job, cb) {
// 如果現在沒有準備好的子進程,并且總子進程數已經超過cpu的個數了,就讓作業先排隊等待
if (!readyPool.length && poolSize > cpus)
return awaiting.push([ doWork, job, cb ]);
// 如果有空閑的子進程則取出第一個使用,沒有的話就新建一個子進程
var child = readyPool.length
? readyPool.shift()
: (poolSize++, cp.fork(workModule));
var cbTriggered = false;
child
// 先刪除原來的監聽
.removeAllListeners()
.once('error', function(err) {
if (!cbTriggered) {
cb(err);
cbTriggered = true;
}
child.kill();
})
.once('exit', function() {
if (!cbTriggered)
cb(new Error('Child exited with code: ' + code));
// 進程關閉的時候將它從隊列中踢出
poolSize--;
var childIdx = readyPool.indexOf(child);
if (childIdx > -1) readyPool.splice(childIdx, 1);
})
.once('message', function(msg) {
cb(null, msg);
cbTriggered = true;
// 子進程再次就緒,將其加回readPool
readyPool.push(child);
// 如果現在有等待的任務,運行之
if (awaiting.length) setImmediate.apply(null, awaiting.shift());
})
}
}
在這段代碼的最后我是有一些疑問的,因為當子進程收到message的時候,進行了異步回調。因為當時沒太能分清異步與多線程的區別。所以我當時就不太懂回調都還沒有結束,為什么就能回收這個進程了?
那么這邊也區分一下異步與多線程。
- 異步:首先說一下DMA,DMA就是直接內存訪問的意思,也就是說,擁有DMA功能的硬件在和內存進行數據交換的時候可以不消耗CPU資源。只要CPU在發起數據傳輸時發送一個指令,硬件就開始自己和內存交換數據,在傳輸完成之后硬件會觸發一個中斷來通知操作完成。這些無須消耗CPU時間的I/O操作正是異步操作的硬件基礎。所以即使在DOS這樣的單進程(而且無線程概念)系統中也同樣可以發起異步的DMA操作。因為異步操作無須額外的線程負擔,并且使用回調的方式進行處理,在設計良好的情況下,處理函數可以不必使用共享變量(即使無法完全不用,最起碼可以減少 共享變量的數量),減少了死鎖的可能。當然異步操作也并非完美無暇。編寫異步操作的復雜程度較高,程序主要使用回調方式進行處理,與普通人的思維方式有些出入,而且難以調試。
- 多線程:線程不是一個計算機硬件的功能,而是操作系統提供的一種邏輯功能,線程本質上是進程中一段并發運行的代碼,所以線程需要操作系統投入CPU資源來運行和調度。多線程的優點很明顯,線程中的處理程序依然是順序執行,符合普通人的思維習慣,所以編程簡單。但是多線程的缺點也同樣明顯,線程的使用(濫用)會給系統帶來上下文切換的額外負擔。并且線程間的共享變量可能造成死鎖的出現。
詳細可見:淺談多線程和異步
同步運行
其實在異步的API介紹完以后,同步的API就顯得很簡單了。因為它和異步的API基本上是一樣的,只不過在實現的過程當中會阻塞掉主線程,直到子進程模塊完成。
-
如果只想同步執行一個單獨的命令,并且得到輸出,那么可以使用execFileSync
var ex = require('child_process').execFileSync; var stdout = ex('echo', ['hello']).toString(); console.log(stdout);
-
如果想程序式同步執行多個命令,并且命令之間的結果存在相互依賴的關系,可以使用spawnSync
var sp = require('child_process').spawnSync; var ps = sp('ps', ['aux']); var grep = sp('grep', ['node']) { input: ps.stdout; encoding: 'utf-8' }); console.log(grep);
同步子進程得到的結果包含了很多的細節,這也是使用spawnSync的另外一個好處。
當然execSync也是同樣的使用方法,這里不再贅述
同步子進程中的異常處理
如果在execSync或execFileSync執行的結果中返回的是一個非零狀態,這種情況下,將會有異常拋出。這個拋出的異常對象將會包含我們在使用spawnExec返回的結果里的所有東西。我們可以訪問狀態編碼里的重要信息和stderr流。
var ex = require('child-process').execFileSync;
try {
ex('cd', ['non-existent-dir'], {
encoding: 'utf-8'
});
} catch(err) {
console.error('exit status was', err.status);
console.error('stderr', err.stderr);
}