JavaScript 是个灵活的脚本语言,能方便的处理业务逻辑。当须要传输通讯时,咱们大多选择 JSON 或 XML 格式。javascript
但在数据长度很是苛刻的状况下,文本协议的效率就很是低了,这时不得不使用二进制格式。css
去年的今天,在折腾一个 先后端结合的 WAF 时,就遇到了这个麻烦。html
由于前端脚本须要采集很多数据,而最终是隐写在某个 cookie 里的,所以可用的长度很是有限,只有几十个字节。前端
若是不假思索就用 JSON 的话,光一个标记字段 {"enableXX": true}
就占去了一半长度。然而在二进制里,标记 true 或 false 不过是 1 个比特的事,能够节省上百倍的空间。java
同时,数据还要通过校验、加密等环节,只有使用二进制格式,才能方便的调用这些算法。nginx
不过,JavaScript 并不支持二进制。git
这里的「不支持」不是说「没法实现」,而是没法「优雅实现」。语言的发明,就是用来优雅解决问题的。即便没有语言,人类也能够用机器指令来编写程序。github
若是非要用 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 来运行。
能将 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; // 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)
这样,原先的索引操做,就变成函数调用了。咱们就能接管内存的读写,而且没有任何兼容性问题!
而后实现 八、1六、32 位有无符号的版本。经过 JS 的 Array 来模拟,很是简单。
麻烦的是模拟 Float32
和 Float64
两个类型。不过本次 C 程序中并未用到浮点,因此就暂不实现了。
到此,兼容性问题就解决了。
解决了这些缺陷,咱们就能够愉快的在 JS 中使用 C 逻辑了。
做为脚本,只需关心采集哪些数据。这样 JS 代码就很是的优雅:
数据的储存、加密、编码,这些底层数据操做,则经过 C 实现。
编译时使用 -Os
参数优化体积。最终的 JS 混淆压缩以后,还不到 2 KB,十分小巧精炼。
更完美的是,咱们只需维护一份代码,便可同时编译出前端和后端两个版本。
因而,这个「先后端 WAF」开发就容易多了。
全部的数据结构和算法,都由 C 实现。前端编译成 JS 代码,后端编译成 lua 模块,供 nginx-lua 使用。
先后端的脚本,都只需关注业务功能便可,彻底不用涉及数据层面的细节。
事实上,还有第三个版本 —— 本地版。
由于全部的 C 代码都在一块儿,所以能够方便的编写测试程序。
这样就无需启动 WebServer、打开浏览器来测试了。只需模拟一些数据,直接运行程序便可测试,很是轻量。
同时借助 IDE,调试起来更容易。
每一门语言都有各自的优缺点。将不一样语言的优点相互结合,可让程序变得更优雅、更完美。