JavaScript是一种灵活的脚本语言,可以方便地处理业务逻辑。当需要传输通信时,我们大多选择JSON或XML格式。但是,当数据长度要求很严格时,文本协议的效率很低,这时候就不得不使用二进制格式了。去年的今天,在折腾一个前后端结合的WAF的时候,遇到了这个麻烦。因为前端脚本需要收集大量的数据,最后隐写在cookie中,可用的长度非常有限,只有几十个字节。如果不假思索地使用JSON,标签字段{"enableXX":true}就占了一半的长度。但是,在二进制中,标记真或假只是1位的事情,可以节省数百倍的空间。同时,数据还要经过校验、加密等环节。只有采用二进制格式才能方便地调用这些算法。优雅的实现然而,JavaScript不支持二进制。这里的“不支持”并不是说“无法实现”,而是“优雅地实现”。语言的发明是用来优雅地解决问题的。即使没有语言,人类也可以使用机器指令编写程序。如果你必须使用JavaScript来操作二进制文件,你最终会得到这样的东西:varflags=+enableXX1<<16|+启用XX2<<15|...这是可能的,但很难看。各种硬编码,各种位运算。但是,对于天生就支持二进制的语言来说,看起来就很优雅了:开发或者简单地定义一个描述。使用的时候不需要关心字段偏移多少,如何读写等细节。为了达到类似的效果,先封装了一个JS版本的结构体://初步方案:封装一个JS结构体vars=newStruct([{name:'month',bit:4,signed:false},..]);s.set('月',12);s.get('月份');隐藏细节,看起来更优雅。优雅但不性感然而,这并不总是感觉最好。结构之类的东西本来应该是语言提供的,现在却需要用额外的代码来实现,而且还是在运行时。另外后端解码是用C实现的,所以要维护两套代码。一旦数据结构或算法发生变化,同时更新JS和C是非常麻烦的。所以我就想,能不能共享一套前后端都用的C代码?也就是说,它需要能够将C编译成JS才能运行。知道emscripten可以把C编译成JS的工具有很多,最专业的当属emscripten了。emscripten的使用非常简单,类似于传统的C编译器,只是生成的是JS代码。./emcchello.c-ohello.html//hello.c#include#includeintmain(){time_tnow;time(&now);printf("HelloWorld:%s",ctime(&now));return0;}编译后可以运行:很有意思~大家可以试试,这里就不介绍了。实用性缺陷然而,我们关心的不是好玩,而是实用性。事实上,即使是一个HelloWorld编译出来的JS也有10000多行,高达数百KB。即使压缩再GZIP,也有几十KB。同时,emscripten使用了asm.js规范,内存访问是通过TypedArray来实现的。这意味着IE10以下的用户将无法运行。这也是不能接受的。因此,我们不得不做出如下改进:缩小体积,增加兼容性。首先,依靠emscripten本身,看看我们是否可以通过设置参数来达到我们的目的。但是经过一些尝试,它没有奏效。那只能靠你自己了。缩小尺寸为什么最终脚本这么大,里面有什么?分析了下面的内容,大致有这几部分:辅助函数接口模拟初始化操作Runtime函数程序逻辑字符串和二进制转换等辅助函数,提供回调封装等这些基本不用,我们可以自己写一个专门的回调函数我们自己。界面模拟提供文件、终端、网络、渲染等界面。之前看过用emscripten移植的客户端游戏,好像模拟了很多界面。初始化操作全局内存,运行时,各个模块的初始化。Runtimefunctions纯C只能做简单的计算,很多函数都依赖runtimefunctions。然而,一些常用功能背后的实现却异常复杂。比如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和数组一样,是通过索引来访问的:varbuf=newUInt8Array(100);buf[0]=123;//setalert(buf[0]);//get但是在JS中无法重写[]操作符,所以很难将其变成setter和getter。而且不支持TypedArray的都是低版本的IE,更别说ES6的那些特性了。于是琢磨着IE的私有接口。例如,使用onpropertychange事件来模拟setter。虽然这是非常低效的,但getter仍然不容易实现。经过一番考虑,我决定不使用hooks,而是直接从源头上解决——修改语法!我们使用正则规则找出源码中的赋值操作:HEAP[index]=val;将其替换为:HEAP_SET(index,val);同样,将读操作:HEAP[index]替换为:HEAP_GET(index),原来的索引操作变成了函数调用。我们可以在没有任何兼容性问题的情况下接管内存的读写!然后实现8、16、32位无符号版本。通过JSArray来模拟非常简单。问题在于模拟Float32和Float64这两种类型。不过这个C程序没有用到浮点数,所以暂时不实现。至此,兼容性问题就解决了。当你解决完这些缺陷后,我们就可以愉快地在JS中使用C逻辑了。作为脚本,你只需要关心收集哪些数据。这样一来,JS代码就非常优雅了:数据的存储、加密、编码,这些底层的数据操作都由C来实现,编译时使用-Os参数优化大小。最后JS混淆压缩后不到2KB,非常小巧精致。更重要的是,我们只需要维护一份代码就可以同时编译前后端版本。因此,这种“前后端WAF”的开发就容易多了。所有的数据结构和算法都用C实现,前端编译成JS代码,后端编译成lua模块供nginx-lua使用。前后端脚本只需要关注业务功能,完全不涉及数据层面的细节。Beta版其实还有第三个版本——本地版。因为所有的C代码都放在一起,方便写测试程序。这消除了启动WebServer并打开浏览器进行测试的需要。只需要模拟一些数据,直接运行程序即可测试,非常轻量级。同时,有了IDE的帮助,调试起来更加容易。总结每种语言都有自己的优点和缺点。结合不同语言的优点可以使程序更加优雅和高效。