查看原文
其他

记一次完整 C++ 项目编译成 WebAssembly 的实践

张翰(门柳) 淘系技术 2021-08-11

作者| 张翰(门柳) 

出品|阿里巴巴新零售淘系技术部


文知识点提炼:
1、把复杂的 C++ 框架编译成 WebAssembly。
2、在 wasm 模块里调用 DOM API !
3、在 js 和 wasm 之间传递复杂数据结构。
4、对 WebAssembly 技术发展的期待。

上一篇文章《基础为零?如何将 C++ 编译成 WebAssembly》里介绍了怎么把简单的 C++ demo 编译成 WebAssembly,但这是远远不够的。正好手头在写一个 C++ 的项目,功能独立完整也足够复杂(有 2W+ 行代码),就顺便编译成了 WebAssembly,未必是一个合适的使用场景,主要是为了学习这项技术,亲身体验一下过程中遇到的问题。


项目背景



我现在在用 C++ 写一个原生的响应式框架,定位和前端框架差不多,但是用 C++ 来实现,可以有更好的性能,也方便对接各种原生渲染引擎和各种语言。上层开发者仍然以 JS 为开发语言,API 和 Web Component 相似,而且可以像小程序和 Vue.js 那样用模板+数据(声明式+响应式)的方式开发原生 UI,框架本身就不介绍了,本文的重点是涉及 WebAssembly 的部分。


▐  为什么要编译成 WebAssembly ?


框架是 C++ 写的,本身的设计是源码集成进各种渲染容器的,跟随原生 SDK 发版,即使是对接浏览器,也是和浏览器内核代码(如 UC 的 U4 内核)打包在一起,但这样就无法运行在独立的浏览器上,功能无法降级。如果说把 C++ 代码编译成 WebAssembly 的话,那框架就可以从远程加载了再运行,相当于 C++ 的框架也有了动态化的能力。


简单来讲,现在这个 C++ 框架已经能运行在各种原生渲染引擎之上了,我想保持同一份 C++ 源码,让它能运行在干净的浏览器中。


需求分析



▐  要实现的目标


首先细化一下要实现的目标,下面是一段使用响应式框架 API 开发的代码,它可以运行在原生渲染引擎上,目标是让这段代码能运行在干净的浏览器里:


// 1. 自定义一个组件class HelloWorld extends ReactiveElement { /* ... */ }// 2. 向环境中注册组件,给定一个名称customElements.define('hello-world', HelloWorld)// 3. 定义组件的模板customElements.defineTemplate(HelloWorld, { type: 'h1', // 添加数据绑定,表示 h1 的 innerText 是由表达式 message 计算出来的 innerText: { '@binding': '`Say: ${message}`' }})// 4. 创建组件,传递初始数据const app = new HelloWorld({ message: 'Hi~' })// 5. 把组件挂载到 #rootcustomElements.mount('#root', app)// 6. 更新组件的数据,会自动触发 UI 的更新app.setState({ message: "What's up!"})

这段代码是一个完整的例子, 1, 2, 4 是 Web Component 的标准写法(用 ReactiveElement 代替 HTMLElement),浏览器已经支持,5 是用于挂载节点的语法糖, 3 和 6 是新增的 API,用于定义组件的模板和传递数据。方案算是对 Web Component 的增强,加入了模板和数据绑定的能力,在真实场景里模板不会是手写的,而是由小程序、Vue.js 所定义的模板语法编译而来。


看起来用 JS 写个 polyfill 就可以搞定。但是模板的运算和数据绑定怎么实现?里面是可以包含循环、分支和表达式的,前端框架的做法是把它编译成 js 代码,如果写 polyfill,很可能又写出了一个前端框架,或者基于现有前端框架做封装,但是这样就和原生框架的行为不一致了。


▐  遇到的问题


想用响应式框架跑通上面的例子,就是要实现这么一个调用链路:


demo.js <-----> [响应式框架] <-----> DOM API


在原生框架中,ReactiveElement class 和 customElements 上的接口都是由 C++ 实现的,类似于 DOM API,有 ES6 的类,也有普通函数,接受的参数有 class(Function),String 和 Object 等各种类型。然而 wasm 目前只可以 import 和 export C 语言函数风格的 API,而且参数只有四种数据类型(i32, i64, f32, f64),都是数字,可以理解为赤裸裸的二进制编码,没法直接传递复杂的类型和数据结构。所以在浏览器中这些高级类型的 API 必须靠 JS 来封装,中间还需要一个机制实现跨语言转换复杂的数据结构。


WebAssembly 是一种编译目标,虽然运行时是 wasm 和 JS 之间互相调用,但是写代码的时候感知到的是 C++ 和 JS 之间的互相调用。文中说的 JS 和 C++ 的调用,实际意思是 JS 和 wasm 模块之间的调用。


另外,如果要实现在浏览器里渲染和更新 UI 的话,就必须要用到 DOM API,众所周知 WebAssembly 调用不了 DOM API,也不是个靠谱的用法。原生框架提供了 C++ 的 ComponentRenderer 抽象类来对接渲染引擎,不同的渲染引擎分别实现这个类然后注入框架,不管靠谱不靠谱,在浏览器里也只能靠 JS 实现这个渲染器了,封装 DOM 操作然后把接口传给 C++ 调用,而且也要传递复杂的数据结构。如果想精确更新某个特定组件节点,还得解决 C++ 组件和 JS 组件一对一 binding 的问题。


总结下来,是两个问题:


  1. 在 C++ 和 JS 之间传递复杂数据结构。(数据通信)

  2. 实现 C++ 和 JS 复杂数据类型的一对一绑定。(类型绑定)



技术方案



▐  如何编译代码


首先需要确定一下如何把 C++ 文件编译成 wasm 文件,前一篇文章讲提到两种方式,用 Emscripten 会生成 wasm + js 两个文件,可以很好的跑在浏览器和 Node.js 上;用 wasi-sdk 可以编译出独立的 wasm 包,但是想把它运行起来需要运行时给它注入必要的接口。



我的目标是跑在浏览器里,所以用 Emscripten 来编译,生成一份 “js glue” 文件来辅助运行, 像个奥利奥,有种必须被 js 捏在手里运行的感觉,但是在浏览器里不得不这样。这很可能是浏览器里 WebAssembly 和 JS 需要长期保持的关系…… 有提案说用 <script type="module"> 的方式加载 wasm 文件,这样可以不依赖 js 进行初始化,但是离实现这一步还早呢。



具体怎么编译?参考 Emscripten 官方文档配置一下编译脚本就好了,这里列出响应式框架的其中一部分编译脚本,可供参考。



使用 wasi-sdk 的编译链路我也在尝试,可以编译出来独立的 wasm 包,但是目前还没把它运行起来,需要在运行时注入响应式框架依赖的渲染接口。


算上上面的 demo 文件,一共有三个文件,大概的执行顺序是 :
1. 加载框架的 js 文件;
2. 框架 js 文件自动加载并编译 wasm 文件;
3. 执行 demo 文件。

伪代码如下:


<script src="path/to/wasm-framework.js"></script><script> initializeWasmAPIs().then(exports => { // wasm 初始化完成后,动态插入 demo 的脚本 const $script = document.createElement('script') $script.setAttribute('src', `path/to/demo.js`) document.body.appendChild($script) })</script>


▐  接口的互相调用


明确了打包生成的代码是 js-wasm-js 这种奥利奥格式的,代入上面提到的调用链路里就是这样:


(A) (B) (C) (D)demo.js <-----> [js <---> wasm <---> js] <-----> DOM API


看起来这个渲染链路是比较长的,但是中括号里的属于框架内部的调用,不被外界感知的。全部展开来,可以分成 A/B/C/D 四个环节的接口调用,其中 (A) 和 (D) 都是 js 和 js 之间的调用,封装 API 而已,没什么难的,关键要搞定 (B) 和 (C) 的调用。


再回过头来看 wasm 在运行时的层次结果,如下图左半部分,demo.js 属于逻辑 JS,运行与响应式框架之上,响应式框架运行与浏览器之上。再把每一层展开,可以看到每层之间的接口调用关系,如下图右半部分所示:



图中 (A) 透出的接口就是上面 demo.js 用到的接口,主要是一个 ReactiveElement 的 ES6 class 和 customElements 这个对象。(D) 就是前端很熟悉的 DOM API,不再赘述。中间响应式框架这层把 wasm + js 两个文件画成三层的奥利奥结构,是因为虽然 js 文件只有一个,但是从逻辑上区分它做了两件事情,分别处理 wasm 的输入和输出。


前面提到过 wasm 通过 import/export 的方式实现和外部的接口调用,而且接口只能是普通的函数而且参数只能是数字,分别对应了图中的 (C) 和 (B)。所以运行与 wasm 下方的 js renderer 是负责封装 DOM API,把它转成 wasm 声明的 import 接口 (C),在实例化 wasm 包的时候把接口注入进去。


(C) 接口的详细设计如下图所示,以 _ui 开头的都是响应式框架声明的、需要在运行时注入的接口。



同理,wasm 导出的接口也是 C 语言函数风格的,想要转成 Web Component 风格的 API 需要在 JS 层做封装,这就是图中 js api wrapper 这一层所做的事,拿到 wasm 导出的接口 (B) 并把它封装成 (A)。


(B) 接口的详细格式如下图所示,以 _ui 开头的都是响应式框架导出的。



在实际编译的时候,Emscripten 提供了 --pre-js ,--post-js 和 --js-library 等方式注入 JS 代码,具体含义参考其官方文档。我在项目里用 --post-js 打包 api.js 文件实现接口 (B) 到 (A) 的封装,用 --js-library 打包 renderer.js  文件,实现 (D) 到 (C) 的封装。


除了上面提到的接口封装方式以外, Emscripten 还提供了 Embind  Web IDL Binder  的方式绑定 JS 接口,原理和上面方法相同,我觉得封装太厚了就没选择使用。




▐  搞定数据通信


接口调用搞定了,然后还需要搞定传值的问题。wasm 导入和导出的函数参数只能是数字,要传递复杂值,必须借助 WebAssembly.Memory()  ④ 实现,它是由宿主环境开辟的内存,交给 wasm 模块来运行,在运行时这块内存可以被 wasm 模块和宿主环境共同管理,这是 wasm 和宿主环境实现大块数据通信的基础。




传递内存 buffer


如下代码创建了一块初始大小为 256 的内存(单位是“页”,每页64KB),并在实例化时传递给 wasm 模块。


const wasmMemory = new WebAssembly.Memory({ initial: 256 })WebAssembly.instantiate(wasmBinary, { env: { memory: wasmMemory }})


这块内存就是 wasm 模块运行时使用的内存,可以通过 wasm 指令读取、写入以及 grow,对应到 C++ 源码里就是指针和 new/malloc 之类的操作;同时这块内存又是一个 js 变量,也可以在 js 里读写它的值。


所以 js 和 wasm 通信的过程就是:先把想要传递的数据序列化成 ArrayBuffer,然后把 buffer 写入 Memory 并且把数据的起始位置、数据大小传给 wasm 模块,在 wasm 中根据位置取到这块 buffer,最后把 buffer 反序列化成自己想要的类型。伪代码如下:


// 把想要传递的数据转成 ArrayBuffer (假设是 Uint8Array)const dataBuffer = encodeDataByJS({ /* my data */ })
// 向 wasm 申请一段内存,由 wasm 代码负责实现并返回内存内存起始地址const offset = applyMemoryFormWasm(dataBuffer.length)
// 以 unit8 的格式操作 wasm 的内存 (格式应该与 dataBuffer 的格式相同)const heapUint8 = new Uint8Array(wasmMemory.buffer, offset, dataBuffer.length)
// 把数据写入 wasm 内存heapUint8.set(dataBuffer)


传递复杂数据结构


仅支持传递 buffer 并不能解决所有问题,总不能在写代码的时候都去手动操作内存吧,而且 JS 的数据类型和 C++ 的数据类型差别很大,传递数字和数组还好一些,在传递复杂数据结构的时候,如何正确的实现数据的序列化和反序列化?


先说一下通用的解法,这是一个跨语言数据结构转换的问题。面对同一块内存,要让不同的语言都能按照同样的格式(或内存布局)来读取这块内存,就得定义一套语言无关的二进制数据格式。这个在业界有挺多方案的,首先有种叫 BSON   的数据格式,就是二进制版本的 JSON,主要用在 MongoDB 数据库里。另外 Google 的 Protocol Buffers  也是做这个事情的,自定义了一套二进制格式   并且提供了多种语言(没有 JS)的序列化、反序列化 API。另外 Flutter 的 MethodChannel 也是自己设计了一套二进制编码格式 ⑧ ,用于实现跨语言的接口调用。


但是上面这些方式用在 WebAssembly 里都不太合适,或者说太重太麻烦了,现在 WebAssembly 社区在讨论的 WebAssembly Interface Types  就是要解决这个问题的,借鉴 Web IDL 的思路,有希望定义出一套适用于 wasm 的语言无关的类型标准,但是目前还有很多问题需要解决,如 anyref 和 GC objects 的支持等。


思来想去,针对我这个场景,我最终决定用 JSON 字符串实现数据的序列化,因为比较省事……



如上图所示,二进制的 Buffer 是可以在 C++ 和 JS 共同访问的,在 JS 里我把想要传递的数据都用 JSON.stringify() 转成字符串,然后用 TextEncoder 把字符串转成 Uint8Array,解码的时候,先用 TextDecoder 把二进制数据转成字符串,然后用 JSON.parse() 转成我想要的数据结构。在 JS 里我全都用浏览器提供的 API 就可以实现序列化和反序列化,不需要自己写太多代码。在 C++ 里,从二进制数据到 std::string 的转换是很简单的,直接构造和取值就行了,而且巧了,在响应式框架里我设计了一套和 JS 类型对等的 C++ 数据结构,实现了类似 JSON.stringify() 和 JSON.parse() 接口,内部的组件模板、数据绑定、组件状态都可以转换成这套数据类型。所以,对我这个框架来说,每一步的序列化反序列化接口都已经存在了,我只要把它们串起来就行了。


使用 JSON 字符串来传递复杂类型仅仅适用于我这个项目,并不是个通用方案,如何高效实现 wasm 数据通信还要具体情况具体分析。



功能的实现



▐  代码实现


技术方案以及介绍的比较清楚了,具体代码该怎么写就不说太多了,与框架内部的逻辑有关,与 WebAssembly 关系不大。下面就以 setState() 这个接口的伪代码为例,简单捋一下调用流程。



在 demo.js 里调用到的 ReactiveElement 接口是 js 实现的,如上图最左侧代码, setState() 函数的实现就是把数据转成 buffer 塞到 wasm memory 里面,然后调用响应式框架导出的 _component_set_state() 接口设置组件状态。中间的 wasm 代码是由 C++ 编译而来的。在 C++ 的实现里,根据传入的 buffer 位置和大小初始化成 std::string 然后把字符串解析成状态数据,然后调用 C++  对象的 SetState() 方法更新状态。


▐  对接效果


代码编译好之后,准备一个 HTML 页面再启动一个 Web 服务就可以在浏览器里预览 wasm 的例子了。大部分浏览器都已经支持 WebAssembly 了,包括各大 App 里的 webview,下面是一个简单 demo 在微信扫码打开的效果(快看,我把一个 C++ 框架在微信里跑起来了):



图中 f 函数的计算是在 js 里实现的,点击事件也是在 js 里绑定的。首次渲染时 wasm 框架会生成一份 HTML 字符串塞到根节点的 innerHTML 里(比逐个加节点要快),过程中会绑定点击事件。当用户点击了按钮时,原生事件通过 DOM API 透到 js,js 再传给响应式框架的 wasm 包做处理,然后调用 js 绑定的回调函数,回调函数里调用了 setState() 把新状态传给 wasm 框架,wasm 里计算新的节点信息,期间会调用 js 的 calculate 函数,最后调用 DOM API 更新节点(并不是重新生成 HTML 再替换,而是可以精确更新特定的节点)。


wasm 文件理论上也可以调试,可以自己尝试一下,然后你就知道调试 WebAssembly 是多么坑的一件事了。另外 demo 也不一定非得用 JS 写,可以用其他语言写,然后编译成 wasm,也能够运行在响应式框架之上。


▐  性能对比


下面到了大家喜闻乐道的性能测试环节。开头我也说了,把这个项目编译成 WebAssembly 并不是为了追求性能,现阶段 js + wasm 的运行链路大家也都看到了,整个链路跑下来性能未必有什么优势,但是并不代表 wasm 的性能差,未来可优化的潜力是比 js 要高得多的。


所以我并没有很认真的测性能…… 现阶段意义不大。下面是我用一个稍复杂的页面(大概 200+节点),在 Node.js v12 环境下渲染生成 HTML 字符串测出来的结果:



解释一下各个字段。第一列 n 是循环生成 HTML 字符串的次数,第二列和第三列都是使用 wasm 表示使用渲染 HTML 字符串的耗时,它两个的区别是 wasm 代码仅包含了 js api wrapper 和一个几乎为空的 js renderer (参考上文接口的调用链路),因为仅生成 HTML 字符串的话不需要 renderer;而 wasm+js 是包含了 js renderer 代码,这样在渲染过程中就会有比较频繁的 wasm <--> js 调用和传值。最后两列是前面三列数据的比值。


可以看到把 c++ 编译成 wasm,执行时间大概是原版 c++ 的 1.7~1.8 倍,这个基本上比的是执行 wasm 指令和执行原生指令的性能差距,也是符合预期的。而带上 wasm+js 的通信之后,性能变成了 C++ 的 4.4~4.5 倍,所以说大部分性能其实耗在了通信上,而不是指令的运行上。


使用 WebAssembly 调用 DOM API 并不是一个合适的使用场景,和 JS 进行频繁的调用和传值也不是 WebAssembly 的强项,这个测试用例放大了通信开销。



对 WebAssembly 的期待



现在 WebAssembly 虽然还存在很多问题,但它依然是一个很有潜力的技术,在 W3C 有标准化的规范,得到了主流浏览器的一致支持,技术社区也很活跃,大家的关注度也很高。从种种因素来看,它几乎必然是个会被大规模使用的技术,就看在什么时机爆发了。


现在关注 WebAssembly 技术的人,大部分都在思考两个问题:


  • 我能用 WebAssembly 做点什么?

  • 我能为 WebAssembly 做的什么?


第一个问题是 WebAssembly 的使用场景,但是不能为了用新技术而用新技术,得找到最适合使用 WebAssembly 而且具备不可替代性的场景,目前来看,客户端上用在视频、游戏、AR、AI 等领域比较合适。


第二个问题是促进 WebAssembly 的发展,解决实践中的问题帮助它落地。要实现 WebAssembly 在真实业务场景中落地,还需要继续完善基础设施,我很期待社区能够有人解决下面几个问题:


  1. 在工程层面解决 WebAssembly 研发链路的问题。现在无论是开发、编译还是调试都会遇到很多问题,开发体验和开发效率都比较低。目前我个人觉得比较靠谱的三种开发语言是 C++、Rust 和 AssemblyScript,分别面向不同类型的开发者。

  2. 在平台侧解决 WebAssembly 模块的管理问题。解决 wasm 在真正使用时的加载、分发、依赖管理、复用等问题,要是能构建出 npm 这样丰富的生态就好了。

  3. 在客户端/服务端解决 WebAssembly 独立运行时的问题。能够把丰富的平台原生能力,高效的、标准的透出到 wasm 模块中,并且解决性能、稳定性、安全性等问题。

  4. 性能优化!性能优化!性能优化! 无需多说,性能优化永无止境。


等上面的基础设施建设完成后,可以为 WebAssembly 的落地扫清大部分障碍。


最后结合本文的例子,设想在未来使用 WebAssembly 的更合理的架构:



左边是目前在浏览器里运行 WebAssembly 的层次结构,业务逻辑 JS 运行与框架之上,wasm 模块要裹上一层 js glue 才可以运行,浏览器是集 JS 脚本引擎、WebAssembly 运行时、渲染引擎与一体的运行环境,平台的原生能力透过浏览器(或 webview)的壳透出到 JS 环境中。这个架构里最关键的是浏览器,集中实现了各种引擎,功能稳定而且标准化,但是也增大了定制和改造的难度,要引入就都得引入。


右边是一个理想的运行 WebAssembly 的层次结构,在最底层的还是平台原生能力,再往上就不是简单的对接浏览器了,而是将 JS 引擎和 WebAssembly 运行时分开,这两个都可以算作脚本引擎,至于渲染引擎,它应该运行与脚本引擎的下方,属于平台原生能力的一部分(图中未画出来)。有了独立的 WebAssembly 运行时,平台原生能力就能够直接以 wasi 的形式透出,开发和运行 wasm 的时候就不再受 JS 的影响,也有助于实现标准化,向上透出的接口是语言无关的,依然可以被 JS 调用,也可以支持其他语言,也支持其他编译好的 WebAssembly 模块。



写在最后



文章主要讲的是我在响应式框架里的使用 WebAssembly 的经验,虽然未必是一个很合适的使用场景,但是大部分技术方案是通用的,给大家做个参考,写得不对的地方欢迎指正。如果有对 WebAssembly 感兴趣的同学欢迎加入淘宝技术部和我一起交流~

相关链接


1 https://emscripten.org/docs/tools_reference/emcc.html
2 https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html
3 https://emscripten.org/docs/porting/connecting_cpp_and_javascript/WebIDL-Binder.html
4https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly/Memory
5 http://bsonspec.org/
6 https://developers.google.com/protocol-buffers
7 https://developers.google.com/protocol-buffers/docs/encoding
8 https://flutter.dev/docs/development/platform-integration/platform-channels#codec
9 https://hacks.mozilla.org/2019/08/webassembly-interface-types/




One More Thing


Hey,我们现在也在招人。业界领先的淘宝小程序团队现在致力于推进小程序底层能力建设和创意互动项目,我们非常欢迎具备 iOS / Android / C++ / Flutter / 渲染引擎 / JS引擎等技术栈的同学加入我们。欢迎简历投递 Email:fangxi.fx@alibaba-inc.com


END


干货分享
点击下方图片即可阅读


直播通道预开启!独家分享带你玩转Rax
干货 | 第十四届 D2 前端技术论坛 20+ 份精彩演讲 PPT 分享


基础为零?如何将 C++ 编译成 WebAssembly


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存