JavaScript 是個靈活的指令碼語言,能方便的處理業務邏輯。當需要傳輸通訊時,我們大多選擇 JSON 或 XML 格式。
但在資料長度非常苛刻的情況下,文字協議的效率就非常低了,這時不得不使用二進位制格式。
去年的今天,在折騰一個 前後端結合的 WAF 時,就遇到了這個麻煩。
因為前端指令碼需要採集不少資料,而最終是隱寫在某個 cookie 裡的,因此可用的長度非常有限,只有幾十個位元組。
如果不假思索就用 JSON 的話,光一個標記欄位 {"enableXX": true}
就佔去了一半長度。然而在二進位制裡,標記 true 或 false 不過是 1 個位元的事,可以節省上百倍的空間。
同時,資料還要經過校驗、加密等環節,只有使用二進位制格式,才能方便的呼叫這些演算法。
優雅實現
不過,JavaScript 並不支援二進位制。
這裡的「不支援」不是說「無法實現」,而是無法「優雅實現」。語言的發明,就是用來優雅解決問題的。即使沒有語言,人類也可以用機器指令來編寫程式。
如果非要用 JavaScript 操作二進位制,最終就類似這樣:
1 |
var flags = +enableXX1 |
雖然能實現,但很醜陋。各種硬編碼、各種位運算。
然而,對於先天支援二進位制的語言,看起來就十分優雅:
1 2 3 4 5 6 7 8 9 10 11 |
union { struct { int enableXX1: 1; int enableXX2: 1; ... }; int16_t value; } flags; flags.enableXX1 = enableXX1; flags.enableXX2 = enableXX2; |
開發者只需定義一個描述即可。使用時,欄位偏移多少、如何讀寫,這些細節完全不用關心。
為了能達到類似效果,起先封裝了一個 JS 版的結構體:
1 2 3 4 5 6 7 8 |
// 最初方案:封裝一個 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 程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 |
emcc hello.c -o hello.html // hello.c #include #include 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 和陣列一樣,是通過索引來訪問的:
1 2 3 |
var buf = new Uint8Array(100); buf[0] = 123; // set alert(buf[0]); // get |
然而 []
操作符在 JS 裡是無法重寫的,因此難以將其變成 setter 和 getter。況且不支援 TypedArray 的都是低版本 IE,更不用考慮 ES6 的那些特徵。
於是琢磨 IE 的私有介面。比如用 onpropertychange 事件來模擬 setter。不過這樣做效率極低,而且 getter 仍不易實現。
經過一番考慮,決定不用鉤子的方式,而是直接從源頭上解決 —— 修改語法!
我們用正則,找出原始碼中的賦值操作:
1 |
HEAP[index] = val; |
替換成:
1 |
HEAP_SET(index, val); |
類似的,將讀取操作:
1 |
HEAP[index] |
替換成:
1 |
HEAP_GET(index) |
這樣,原先的索引操作,就變成函式呼叫了。我們就能接管記憶體的讀寫,並且沒有任何相容性問題!
然後實現 8、16、32 位有無符號的版本。通過 JS 的 Array 來模擬,非常簡單。
麻煩的是模擬 Float32
和 Float64
兩個型別。不過本次 C 程式中並未用到浮點,所以就暫不實現了。
到此,相容性問題就解決了。
大功告成
解決了這些缺陷,我們就可以愉快的在 JS 中使用 C 邏輯了。
作為指令碼,只需關心採集哪些資料,這樣程式碼就非常的優雅:
資料的儲存、加密、編碼,這些二進位制操作,則通過 C 實現。
編譯時使用 -Os
引數優化體積,最終的 JS 精簡壓縮之後,還不到 2 KB,十分小巧精煉。
於是,這個「前後端 WAF」開發就容易多了。我們只需維護一份程式碼,即可同時編譯出前後端兩個版本!
所有的資料結構和演算法,都由 C 實現。前端編譯成 JS 程式碼,後端編譯成 lua 模組,供 nginx-lua 使用。
前後端的指令碼,都只需關注業務功能即可,完全不用涉及資料層面的細節。
測試版
事實上,還有第三個版本 —— 本地版。
因為所有的 C 程式碼都在一起,因此可以方便的編寫測試程式。
這樣就無需啟動 WebServer、開啟瀏覽器來測試了。只需模擬一些資料,直接執行程式即可測試,非常輕量。
同時藉助 IDE,除錯起來更容易。
小結
每一門語言都有各自的優缺點。將不同語言的優勢相互結合,可以讓程式變得更優雅、更完美。