標籤:ber 輔助 clu pre cti 空間 std 語言 協議
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% 的邏輯代碼了。
增加相容
接著解決記憶體訪問的相容性問題。
首先瞭解下,為何要用 TypedArray。
emscripten 申請了一大塊 ArrayBuffer 來類比記憶體,然後關聯了一些 HEAP 開頭的變數。
這些不同類型的 HEAP 共用同一塊記憶體,這樣就能高效的指標操作。
然而不支援 TypedArray 的瀏覽器,顯然無法運行。所以得提供個 polyfill 相容下。
但經分析,這幾乎不可能實現 —— 因為 TypedArray 和數組一樣,是通過索引來訪問的:
var buf = new Uint8Array(100);buf[0] = 123; // setalert(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 來類比,非常簡單。
麻煩的是類比 Float32 和 Float64 兩個類型。不過本次 C 程式中並未用到浮點,所以就暫不實現了。
到此,相容性問題就解決了。
大功告成
解決了這些缺陷,我們就可以愉快的在 JS 中使用 C 邏輯了。
作為指令碼,只需關心採集哪些資料。這樣 JS 代碼就非常的優雅:
資料的儲存、加密、編碼,這些底層資料操作,則通過 C 實現。
編譯時間使用 -Os 參數最佳化體積。最終的 JS 混淆壓縮之後,還不到 2 KB,十分小巧精鍊。
更完美的是,我們只需維護一份代碼,即可同時編譯出前端和後端兩個版本。
於是,這個「前後端 WAF」開發就容易多了。
所有的資料結構和演算法,都由 C 實現。前端編譯成 JS 代碼,後端編譯成 lua 模組,供 nginx-lua 使用。
前後端的指令碼,都只需關注業務功能即可,完全不用涉及資料層面的細節。
測試版
事實上,還有第三個版本 —— 本地版。
因為所有的 C 代碼都在一起,因此可以方便的編寫測試程式。
這樣就無需啟動 WebServer、開啟瀏覽器來測試了。只需類比一些資料,直接運行程式即可測試,非常輕量。
同時藉助 IDE,調試起來更容易。
小結
每一門語言都有各自的優缺點。將不同語言的優勢相互結合,可以讓程式變得更優雅、更完美。
如何在 JavaScript 中使用 C 程式