SaaS(Software as a Service,軟件即服務),是一種經過互聯網提供軟件服務的模式。服務提供商會全權負責軟件服務的搭建、維護和管理,使得他們的客戶從這些繁瑣的工做中解放出來。對於許多中小型企業而言,SaaS 是採用先進技術的最好途徑。javascript
然而,對於大型企業而言,狀況有所不一樣。出於產品定製、功能穩定以及掌握自身數據資產等方面的考慮,即便成本增長,他們也更樂意把相關服務部署在企業本身的硬件設備上,也就是常說的私有化部署。java
在私有化部署的過程當中,服務提供商首先要確保本身的源代碼不被泄露,不然產品就能夠隨意複製和更改,得不償失。傳統的後端運行環境,如 Java、.NET,其源代碼是通過編譯才部署到服務器上運行的,不存在泄露的風險。而對於應用愈來愈普遍的 Node.js 而言,運行的則是源代碼。即便通過壓縮混淆,也能夠很大程度地還原。node
本文介紹一種可用於 Node.js 端的代碼保護方案,使得 Node.js 項目也能夠放心地進行私有化部署。git
當 V8 編譯 JavaScript 代碼時,解析器將生成一個抽象語法樹,進一步生成字節碼。Node.js 有一個叫作 vm 的內置模塊,建立 vm.Script 的實例時,只要在構造函數中傳入 produceCachedData 屬性,並設爲 true,就能夠獲取對應代碼的字節碼。例如:github
const vm = require('vm');
const CODE = 'console.log("Hello world");'; // 源代碼
const script = new vm.Script(CODE, {
produceCachedData: true
});
const bytecodeBuffer = script.cachedData; // 字節碼
複製代碼
而且,這段字節碼能夠脫離源代碼運行:數據庫
const anotherScript = new vm.Script(' '.repeat(CODE.length), {
cachedData: bytecodeBuffer
});
anotherScript.runInThisContext(); // 'Hello world'
複製代碼
這段代碼看起來不那麼容易理解,主要體如今建立 vm.Script 實例時傳入的第一個參數:npm
首先,建立 vm.Script 實例時,V8 會檢查字節碼(cachedData)是否與源代碼(第一個參數傳入的代碼)匹配,因此第一個參數不能省略。其次,這個檢查很是簡單,它只會對比代碼長度是否一致,因此只要使用與源代碼長度相同的空格,就能夠「欺騙」這個檢查。後端
細心的讀者會發現,這樣一來,其實字節碼並無徹底脫離源代碼運行,由於須要用到源代碼長度這項數據。而實際上,還有其餘方法能夠解決這個問題。試想一下,既然有源代碼長度檢查,那就說明字節碼中也必然保存着源代碼的長度信息,不然就沒法對比了。經過查閱 V8 的相關代碼,能夠發現字節碼的頭部保存着這些信息:數組
// The data header consists of uint32_t-sized entries:
// [0] magic number and (internally provided) external reference count
// [1] version hash
// [2] source hash
// [3] cpu features
// [4] flag hash
複製代碼
其中第 [2] 項 source hash 就是源代碼長度。但由於 Node.js 的 buffer 是 Uint8Array 類型的數組,因此 uint32 數組中的 [2],至關於 uint8 數組中的 [8, 9, 10, 11]。服務器
接着把上述位置的數據提取出來:
const lengthBytes = bytecodeBuffer.slice(8, 12);
複製代碼
其結果相似於:
<Buffer 1b 00 00 00>
這是一種叫作 Little-Endian 的字節序,低位字節排放在內存的低地址端,高位字節排放在內存的高地址端。
<Buffer 1b 00 00 00>
即爲 0x0000001b
,也就是十進制的 27。計算方法以下:
firstByte + (secondByte * 256) + (thirdByte * 256**2) + (forthByte * 256**3)
寫成代碼以下:
const length = lengthBytes.reduce((sum, number, power) => {
return sum += number * Math.pow(256, power);
}, 0); // 27
複製代碼
此外,還有一種更簡單的方法:
const length = bytecodeBuffer.readIntLE(8, 4); // 27
複製代碼
綜上所述,運行字節碼的代碼能夠優化爲:
const length = bytecodeBuffer.readIntLE(8, 4);
const anotherScript = new vm.Script(' '.repeat(length), {
cachedData: bytecodeBuffer
});
anotherScript.runInThisContext();
複製代碼
講清楚原理以後,下面就嘗試編譯一個很簡單的項目,目錄結構以下:
src 目錄內的兩個文件爲源代碼,內容分別爲:
// lib.js
console.log('I am lib');
exports.add = function(a, b) {
return a + b;
};
複製代碼
// index.js
console.log('I am index');
const lib = require('./lib');
console.log(lib.add(1, 2));
複製代碼
dist 目錄用於放置編譯後的代碼。compile.js 即爲執行編譯操做的文件,其流程也很是簡單,讀取源文件內容,編譯爲字節碼後保存爲文件(dist/*.jsc):
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const glob = require('glob'); // 第三方依賴包
const srcPath = path.resolve(__dirname, './src');
const destPath = path.resolve(__dirname, './dist');
glob.sync('**/*.js', { cwd: srcPath }).forEach((filePath) => {
const fullPath = path.join(srcPath, filePath);
const code = fs.readFileSync(fullPath, 'utf8');
const script = new vm.Script(code, {
produceCachedData: true
});
fs.writeFileSync(
path.join(destPath, filePath).replace(/\.js$/, '.jsc'),
script.cachedData
);
});
複製代碼
運行 node compile 後,就能夠在 dist 目錄內生成源代碼對應的字節碼文件,接下來就是運行字節碼文件。然而,直接執行 node index.jsc 是沒法運行的,由於 Node.js 在默認狀況下會把目標文件當作 JavaScript 源代碼來執行。
此時,就須要對 jsc 文件使用特殊的加載邏輯。在 dist 目錄內新建文件 main.js,內容以下:
const Module = require('module');
const path = require('path');
const fs = require('fs');
const vm = require('vm');
// 加載 jsc 文件的擴展
Module._extensions['.jsc'] = function(module, filename) {
const bytecodeBuffer = fs.readFileSync(filename);
const length = bytecodeBuffer.readIntLE(8, 4);
const script = new vm.Script(' '.repeat(length), {
cachedData: bytecodeBuffer
});
script.runInThisContext();
};
// 調用字節碼文件
require('./index');
複製代碼
執行 node dist/main,雖然 jsc 文件能夠加載進來了,可是就出現了另外一段異常信息:
ReferenceError: require is not defined
這是個奇怪的問題,在 Node.js 中,require 是個很基礎的函數,怎麼會未定義呢?原來,Node.js 在編譯 js 文件的過程當中會對其內容進行包裝。以 index.js 爲例,包裝後的代碼以下:
(function (exports, require, module, __filename, __dirname) {
console.log('I am index');
const lib = require('./lib');
console.log(lib.add(1, 2));
});
複製代碼
包裝這個操做並不在編譯字節碼這個步驟裏面,而是在以前執行。因此,要在 compile.js 補上包裝(Module.wrap)操做:
const script = new vm.Script(Module.wrap(code), {
produceCachedData: true
});
複製代碼
加上包裝以後,script.runInThisContext 就會返回一個函數,執行這個函數才能運行模塊,修改代碼以下:
Module._extensions['.jsc'] = function(module, filename) {
// 省略 N 行代碼
const compiledWrapper = script.runInThisContext();
return compiledWrapper.apply(module.exports, [
module.exports,
id => module.require(id),
module,
filename,
path.dirname(filename),
process,
global
]);
};
複製代碼
再次執行 node dist/main.js,出現了另外一條錯誤信息:
SyntaxError: Unexpected end of input
這是一個讓人一臉懵逼,不知道從何查起的錯誤。可是,仔細觀察控制檯又能夠發現,在錯誤信息以前,兩條日誌已經打印出來了:
I am index
I am lib
因而可知,錯誤信息是執行 lib.add 時產生的。因此,結論就是,函數之外的邏輯能夠正常執行,函數內部的邏輯執行失敗。
回想 V8 編譯的流程。它解析 JavaScript 代碼的過程當中,Toplevel 部分會被解釋器徹底解析,生成抽象語法樹以及字節碼。Non Toplevel 部分僅僅被預解析(語法檢查),不會生成語法樹,更不會生成字節碼。Non Toplevel 部分,即函數體部分,只有在函數被調用的時候纔會被編譯。
因此問題也就一目瞭然了:函數體沒有編譯成字節碼。幸虧,這種行爲也是能夠更改的:
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');
複製代碼
設置了 no-lazy 標誌後再執行 node compile 進行編譯,函數體也能夠被徹底解析了。最終 compile.js 代碼以下:
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const Module = require('module');
const glob = require('glob');
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');
const srcPath = path.resolve(__dirname, './src');
const destPath = path.resolve(__dirname, './dist');
glob.sync('**/*.js', { cwd: srcPath }).forEach((filePath) => {
const fullPath = path.join(srcPath, filePath);
const code = fs.readFileSync(fullPath, 'utf8');
const script = new vm.Script(Module.wrap(code), {
produceCachedData: true
});
fs.writeFileSync(
path.join(destPath, filePath).replace(/\.js$/, '.jsc'),
script.cachedData
);
});
複製代碼
dist/main.js 代碼以下:
const Module = require('module');
const path = require('path');
const fs = require('fs');
const vm = require('vm');
const v8 = require('v8');
v8.setFlagsFromString('--no-lazy');
Module._extensions['.jsc'] = function(module, filename) {
const bytecodeBuffer = fs.readFileSync(filename);
const length = bytecodeBuffer.readIntLE(8, 4);
const script = new vm.Script(' '.repeat(length), {
cachedData: bytecodeBuffer
});
const compiledWrapper = script.runInThisContext();
return compiledWrapper.apply(module.exports, [
module.exports,
id => module.require(id),
module,
filename,
path.dirname(filename),
process,
global
]);
};
require('./index');
複製代碼
實際上,若是你真的須要把 JavaScript 源代碼編譯成字節碼,並不須要本身去編寫這麼多的代碼。npm 平臺上已經有一個叫作 bytenode 的包能夠完成這些事情,而且它在細節和兼容性上作得更好。
雖然編譯成字節碼後能夠保護源代碼,但字節碼也會存在一些問題:
做爲一名聰明的讀者,你一定能猜到,本文是以倒敘的方式寫的。筆者是先使用 bytenode 完成了需求,再研究其原理。
本文同時發表於做者我的博客:《保護 Node.js 項目的源代碼》