當我們需要做上下文隔離來運行未知的異常複雜的異步代碼時,異常捕獲是必須要考慮的因素。假設有以下的場景:
vm 所執行的代碼可能來自於第三方,但是整個項目是提供基礎鏡像,第三方基於鏡像自行部署的,因此不關心 vm 裡的代碼安全問題,不用用到 vm2。
vm 裡的代碼是有可能出錯的,錯誤可能來自於同步代碼、異步代碼或者未處理的 Promise 錯誤。
vm 代碼是異步並行的,假設每次執行 vm 代碼都有一個 id。
vm 裡的代碼即使出錯,也必須要知道是哪個 id 的 vm 代碼執行出錯了,來執行兜底的策略。
我們先來嘗試使用不同的方式來捕獲錯誤。
1. 使用 process 捕獲在 node 裡,如果要捕獲未知的錯誤,我們當然可以用 process 來捕獲:
process.on('unhandledRejection', (err) => { });
process.on('uncaughtException', (err) => { });這代碼不僅可以捕獲同步、異步錯誤,也能捕獲 Promise 錯誤。但同時,我們從 err對象上也獲取不了出錯時候的上下文信息。像背景裡的要求,就不知道是哪個 id 的 vm 出錯了。
2. 使用 try...catch如果以 vm 來執行代碼的話,我們大可以在代碼的外部包裹 try...catch來捕獲異常。看下面的例子, try...catch捕獲到了錯誤,錯誤就沒再冒泡到 process 。
const vm = require('vm');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
const script = new vm.Script(` try { throw new Error('from vm') } catch (err) { console.log(err) }`);
script.runInNewContext({ Error, console });
考慮異步錯誤1. vm.Script 異步函數改寫上面的例子,將錯誤在異步函數裡拋出, try...catch捕獲不到錯誤,錯誤冒泡到 process ,被 uncaughtException事件捕獲到:
const vm = require('vm');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` try { setTimeout(() => { throw new Error('from vm') }) } catch (err) { console.log(err) }`);
script.runInNewContext({ Error, console, setTimeout });那有什麼辦法捕獲異步錯誤嗎?辦法還是有的,node 裡有個 domain 模塊,可以用來捕獲異步錯誤。(雖然已經標記為廢棄狀態,但是已經用 async_hooks 重寫了,意味著即使真的被廢棄,也能自己實現一個)
繼續改寫上面的例子,將 vm 放在 domain 裡執行,可以看到錯誤被 domain 捕獲到了:
const vm = require('vm');const domain = require('domain');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` try { setTimeout(() => { throw new Error('from vm') }) } catch (err) { console.log(err) }`);
const d = domain.create();
d.on('error', (err) => { console.log('[domain-error]:', err);});
d.run(() => { script.runInNewContext({ Error, console, setTimeout });});
2. Promise 錯誤但是假如將上一個例子的 vm 代碼改成 Promise 執行呢?domain 捕獲不到錯誤,錯誤冒泡到 process 上:
const vm = require('vm');const domain = require('domain');
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', err);});
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', err);});
const script = new vm.Script(` Promise.resolve().then(() => { throw new Error('notExistPromiseFunc') })`);
const d = domain.create();
d.on('error', (err) => { console.log('[domain-error]:', err);});
d.run(() => { script.runInNewContext({ Error, console, setTimeout });});為什麼會出現這樣的情況?node 文檔裡是這麼說的
Domains will not interfere with the error handling mechanisms for promises. In other words, no 'error' event will be emitted for unhandled Promise rejections.
那有什麼辦法嗎?這裡想了兩個比較騷的寫法。
一、使用 filename我們知道 vm 在執行的時候,是可以提供一個 filename屬性,在錯誤的時候,會被添加到錯誤堆棧內。默認值是 'evalmachine.<anonymous>' 也就是我們上面的錯誤經常看到的第二行代碼錯誤的位置。這就帶來了操作的空間。
const vm = require('vm');const markStart = '<vm-error>';const markEnd = '</vm-error>';
const getContext = () => vm.createContext({ console, process, setTimeout, });
const parseErrorStack = (err) => { const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart); const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) { return errorStr.slice(valueStart + markStart.length, valueEnd); }
console.log('[parse-error]'); return null;};
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', parseErrorStack(err));});
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', parseErrorStack(err));});
const getScript = (flag) => { const filename = `${markStart}${flag}${markEnd}`;
return new vm.Script( ` (() => { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('${flag}')); }, 100) }) })() `, { filename } );};
(async () => { for (let i = 0; i < 3; i++) { await getScript(i).runInContext(getContext()); }})();
// >[unhandledRejection]: 0// >[unhandledRejection]: 1// >[unhandledRejection]: 2看下上面的代碼結構,我們做了幾件事:
在 vm 代碼編譯的時候,以 vm-error 標識符標記了我們要傳遞到錯誤堆棧的值。
在 process 捕獲 Promise 錯誤。
在 process 捕獲到 Promise 錯誤的時候,從錯誤堆棧上根據標識符解析出我們要的值。
但是這樣的代碼存在什麼問題?
最主要的問題在於 filename是編譯進去的,即使生成 v8 代碼緩存的 Buffer,後面用這個 Buffer來編譯一個新的 script 實例,傳遞進新的 filename,仍然改變不了之前的值。所以會帶來代碼每次都需要編譯的成本。
我們可以來實踐以下:
const vm = require('vm');require('v8').setFlagsFromString('--no-lazy');
const markStart = '<vm-error>';const markEnd = '</vm-error>';
const getContext = myVar => vm.createContext({ console, process, setTimeout, myVar,});
const parseErrorStack = (err) => { const errorStr = err.stack;
const valueStart = errorStr.indexOf(markStart); const valueEnd = errorStr.lastIndexOf(markEnd);
if (valueStart !== -1 && valueEnd !== -1) { return errorStr.slice(valueStart + markStart.length, valueEnd); }
console.log('[parse-error]'); return null;};
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection]:', parseErrorStack(err));});
process.on('uncaughtException', (err) => { console.log('[uncaughtException]:', parseErrorStack(err));});
const getFileName = flag => `${markStart}${flag}${markEnd}`;
const code = `(() => { new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(myVar)); }, 100) })})()`;
const scriptCache = new vm.Script(code, { filename: getFileName(-1),});
const scriptCachedData = scriptCache.createCachedData();
const getScript = flag => new vm.Script(' '.repeat(code.length), { filename: getFileName(flag), cachedData: scriptCachedData,});
(async () => { for (let i = 0; i < 3; i++) { await getScript(i).runInContext(getContext(i)); }})();看上面的代碼,對比上一個例子,主要有這幾個改動:
緩存了 vm 代碼編譯後的實例, filename設置的 -1。
循環內的 flag 標誌是通過 myVar注入到 vm 的全局變量,在 vm 裡 throw這個 flag 錯誤值的。
循環內的 vm 執行, filename設置的 0 - 3。
結果:編譯後的代碼實例並不會因為使用 cachedData重新編譯後, filename就會被改變,因此就無法使用 cacheData+filename的方式來既要減少編譯時間又想要自定義錯誤堆棧。
二、重寫 Promise當我們想同步和異步代碼都能捕獲得到,那麼只剩下 Promise 錯誤了。什麼情況會報 Promise 未處理的錯誤呢?也就是沒有寫 catch的情況。那麼如果我們改寫 Promise ,將每個 Promise 都加上一個默認的 catch函數,是否能達到期望呢?
const vm = require('vm');
let processFlag;
process.on('unhandledRejection', (err) => { console.log('[unhandledRejection-processFlag]:', processFlag);});
const getVMPromise = (flag) => { const vmPromise = function (...args) { const p = new Promise(...args);
p.then( () => {}, (err) => { processFlag = flag; throw err; } );
return p; }; ['then', 'catch', 'finally', 'all', 'race', 'allSettled', 'any', 'resolve', 'reject', 'try'].map((key) => { if (Promise[key]) { vmPromise[key] = Promise[key]; } });
return vmPromise;};
const getContext = (flag) => vm.createContext({ Promise: getVMPromise(flag), console, setTimeout, });
const getScript = (flag) => { return new vm.Script(` new Promise((resolve, reject) => { setTimeout(() => { console.log("[vm-current-task]:", "${flag}"); reject() }, (1 + Math.random() * 4) * 1000); }) `);};
for (let i = 0; i < 3; i++) { getScript(i).runInContext(getContext(i));}考察以上的代碼,我們做了這些事:
改寫了 Promise,在 Promise 添加了第一個 then方法來處理錯誤。
在自定義的 Promise 的第一個 then方法裡存儲了當前異步任務的上下文。
將自定義的 Promise 當做全局變量傳遞給 vm。
結果:在一個隨機的任務 ID 上,成功在 process 上捕獲到了上下文的信息。(但是 Promise 實現的精華在於 then之後的鏈式調用,這在上面的代碼是沒有體現的。)
方案可行嗎?看起來是可行的,working on...
關於AlloyTeam
AlloyTeam 是國內影響力最大的前端團隊之一,核心成員來自前 WebQQ 前端團隊。AlloyTeam負責過WebQQ、QQ群、興趣部落、騰訊文檔等大型Web項目,積累了許多豐富寶貴的Web開發經驗。這裡技術氛圍好,領導nice、錢景好,無論你是身經百戰的資深工程師,還是即將從學校步入社會的新人,只要你熱愛挑戰,希望前端技術和我們飛速提高,這裡將是最適合你的地方。加入我們,請將簡歷發送至 alloyteam@qq.com,或直接在公眾號留言~