JavaScript 是個靈活的腳本語言,能方便的處理業務邏輯。當需要傳輸通信時,我們大多選擇 JSON 或 XML 格式。
但在數據長度非常苛刻的情況下,文本協議的效率就非常低了,這時不得不使用二進制格式。
去年的今天,在折騰一個 前后端結合的 WAF 時,就遇到了這個麻煩。
因為前端腳本需要采集不少數據,而最終是隱寫在某個 cookie 里的,因此可用的長度非常有限,只有幾十個字節。
如果不假思索就用 JSON 的話,光一個標記字段 {"enableXX": true}
就占去了一半長度。然而在二進制里,標記 true 或 false 不過是 1 個比特的事,可以節省上百倍的空間。
同時,數據還要經過校驗、加密等環節,只有使用二進制格式,才能方便的調用這些算法。
優雅實現
不過,JavaScript 並不支持二進制。
這里的「不支持」不是說「無法實現」,而是無法「優雅實現」。語言的發明,就是用來優雅解決問題的。即使沒有語言,人類也可以用機器指令來編寫程序。
如果非要用 JavaScript 操作二進制,最終就類似這樣:
var flags = +enableXX1 << 16 | +enableXX2 << 15 | ...
雖然能實現,但很丑陋。各種硬編碼、各種位運算。
然而,對於先天支持二進制的語言,看起來就十分優雅:
union {
struct {
int enableXX1: 1;
int enableXX2: 1;
...
};
int16_t value;
} flags;
flags.enableXX1 = enableXX1;
flags.enableXX2 = enableXX2;
開發者只需定義一個描述即可。使用時,字段偏移多少、如何讀寫,這些細節完全不用關心。
為了能達到類似效果,起先封裝了一個 JS 版的結構體:
// 最初方案:封裝一個 JS 結構體
var s = new Struct([
{name: 'month', bit: 4, signed: false},
...
]);
s.set('month', 12);
s.get('month');
將細節進行了隱藏,看起來就優雅多了。
優雅但不完美
但是,這總感覺不是最完美的。結構體這種東西,本該由語言提供,如今卻要用額外的代碼實現,而且還是在運行期間。
另外,后端解碼是用 C 實現的,所以得維護兩套代碼。一旦數據結構或者算法變了,得同時更新 JS 和 C,很麻煩。
於是琢磨,能否共用一套 C 代碼,同時用於前端和后端?
也就是說,需要能將 C 編譯成 JS 來運行。
認識 emscripten
能將 C 編譯成 JS 的工具有不少,最專業的要數 emscripten。
emscripten 的使用方式很簡單,和傳統 C 編譯器差不多,只不過生成的是 JS 代碼。
emcc hello.c -o hello.html
// hello.c
#include <stdio.h>
#include <time.h>
int main() {
time_t now;
time(&now);
printf("Hello World: %s", ctime(&now));
return 0;
}
編譯之后即可運行:
很有趣吧~ 大家可以嘗試下,這里就不多介紹了。
實用缺陷
然而我們關心的不是有趣,而是實用。
事實上,即使一個 Hello World 編譯出來的 JS 也過萬行,多達數百 KB。就算壓縮再 GZIP,仍有幾十 KB。
同時 emscripten 使用了 asm.js 規范,內存訪問是通過 TypedArray 實現的。
這意味着 IE10 以下的用戶都無法運行。這也是不可接受的。
因此,我們得做如下改進:
-
減少體積
-
增加兼容
首先寄托 emscripten 本身,看看能不能通過設置參數,來達到我們的目的。
不過一番嘗試之后,並沒有成功。那只能自己動手實現了。
減少體積
為什么最終腳本會那么大,里面都放了些什么?分析了下內容,大致有這幾個部分:
-
輔助功能
-
接口模擬
-
初始化操作
-
運行時函數
-
程序邏輯
輔助功能
比如字符串和二進制轉換、提供回調包裝等。這些基本都是用不着的,我們可以給自己寫個特殊的回調函數。
接口模擬
提供文件、終端、網絡、渲染等接口。之前見過用 emscripten 移植的客戶端游戲,看來模擬了不少接口。
初始化操作
全局內存、運行時、各種模塊的初始化。
運行時函數
純粹的 C 只能做簡單的計算,很多功能都依靠運行時函數。
不過,有些常用的函數,其背后的實現是及其復雜的。例如 malloc 和 free,對應的 JS 有近 2000 行!
程序邏輯
這才是 C 程序真正對應的 JS 代碼。因為編譯時經過 LLVM 的優化,邏輯可能變得面目全非了。
這部分代碼量不大,是我們真正想要的。
事實上,如果程序沒有用到一些特殊功能的話,把邏輯函數單獨摳出來,仍然是可以運行的!
考慮到我們的 C 程序非常簡單,所以簡單粗暴的提取出來,也是沒問題的。
C 程序對應的 JS 邏輯位於 // EMSCRIPTEN_START_FUNCS
和 // EMSCRIPTEN_END_FUNCS
之間。過濾掉運行時函數,剩下的就是 100% 的邏輯代碼了。
增加兼容
接着解決內存訪問的兼容性問題。
在很老版本的 emscripten 里,是可以選擇是否使用 TypedArray 的。如果不用,則通過 JS Array 來實現。但如今早已去除了這個參數,只能使用 TypedArray。
首先了解下,為何要用 TypedArray。
emscripten 申請了一大塊 ArrayBuffer 來模擬內存,然后關聯了一些 HEAP
開頭的變量。
這些不同類型的 HEAP 共享同一塊內存,這樣就能高效的指針操作。
然而不支持 TypedArray 的瀏覽器,顯然無法運行。所以得提供個 polyfill 兼容下。
但經分析,這幾乎不可能實現 —— 因為 TypedArray 和數組一樣,是通過索引來訪問的:
var buf = new Uint8Array(100);
buf[0] = 123; // set
alert(buf[0]); // get
然而 []
操作符在 JS 里是無法重寫的,因此難以將其變成 setter 和 getter。況且不支持 TypedArray 的都是低版本 IE,更不用考慮 ES6 的那些特征。
於是琢磨 IE 的私有接口。比如用 onpropertychange 事件來模擬 setter。不過這樣做效率極低,而且 getter 仍不易實現。
經過一番考慮,決定不用鈎子的方式,而是直接從源頭上解決 —— 修改語法!
我們用正則,找出源碼中的賦值操作:
HEAP[index] = val;
替換成:
HEAP_SET(index, val);
類似的,將讀取操作:
HEAP[index]
替換成:
HEAP_GET(index)
這樣,原先的索引操作,就變成函數調用了。我們就能接管內存的讀寫,並且沒有任何兼容性問題!
然后實現 8、16、32 位有無符號的版本。通過 JS 的 Array 來模擬,非常簡單。麻煩的是模擬 Float 類型,不過 C 程序中未用到浮點,所以就沒實現。
如果支持 TypedArray,則使用原生的接口;否則,用 Array 模擬版本。
這樣, 既保障了高版本瀏覽器的性能,又兼顧了老瀏覽器的功能。
大功告成
解決了這些缺陷,我們就可以愉快的在 JS 中使用 C 邏輯了。
腳本,只關心業務邏輯。例如采集哪些數據,這樣代碼就非常的優雅:
數據的儲存、加密、編碼,這些二進制操作,則通過 C 實現。
編譯時使用 -Os
參數優化體積,最終的 JS 精簡壓縮之后,還不到 2 KB,十分小巧精煉。
於是,這個「前后端 WAF」開發就容易多了。我們只需維護一份代碼,即可同時編譯出前后端兩個版本!
所有的數據結構和算法,都由 C 實現。前端編譯成 JS 代碼,后端編譯成 lua 模塊,供 nginx-lua 使用。
前后端的腳本,都只需關注業務功能即可,完全不用涉及數據層面的細節。
測試版
事實上,還有第三個版本 —— 本地版。
因為所有的 C 代碼都在一起,因此可以方便的編寫測試程序。
這樣就無需啟動 WebServer、打開瀏覽器來測試了。只需模擬一些數據,直接運行程序即可測試,非常輕量。
同時借助 IDE,調試起來更容易。
小結
每一門語言都有各自的優缺點。將不同語言的優勢相互結合,可以讓程序變得更優雅、更完美。