├── README.md ├── WebAssembly专栏 ├── 1.浅述WebAssembly历史.md ├── 2.Emscripten使用入门.md ├── 3. 打造基于WASM的高性能安全沙盒.md └── images │ ├── asmjs.benchmark.png │ ├── asmjs.png │ ├── benchmarks.png │ ├── c2j.png │ ├── debugging.png │ ├── emcc.png │ ├── emscripten.png │ ├── nacl.png │ ├── opcode.webp │ └── wasm.webp ├── 深入浅出V8优化 ├── 1. Smi和HeapNumber.md ├── 2. 字符串的优化.md └── images │ ├── smi.bool.png │ ├── smi.heapnumber.png │ ├── smi.heapobject.png │ ├── smi.iee754.impl.png │ ├── smi.iee754.png │ ├── smi.jsobject.png │ ├── smi.smi.png │ ├── string.cons.jpg │ ├── string.gc.png │ ├── string.gc2.jpg │ ├── string.layout.png │ └── string.seq.jpg ├── 深入浅出动态化SSR服务 ├── 深入浅出动态化SSR服务(一).md ├── 深入浅出动态化SSR服务(三).md └── 深入浅出动态化SSR服务(二).md └── 高性能WASM播放器实现 ├── images ├── pic1.gif ├── pic2.png ├── pic3.png ├── pic4.png └── pic5.png └── 高性能WASM播放器实现.md /README.md: -------------------------------------------------------------------------------- 1 | ### 目录 2 | * [高性能WASM播放器实现](https://github.com/ErosZy/md/blob/master/%E9%AB%98%E6%80%A7%E8%83%BDWASM%E6%92%AD%E6%94%BE%E5%99%A8%E5%AE%9E%E7%8E%B0/%E9%AB%98%E6%80%A7%E8%83%BDWASM%E6%92%AD%E6%94%BE%E5%99%A8%E5%AE%9E%E7%8E%B0.md)(2020-07-26) 3 | * [InfoQ - 深入浅出动态化SSR服务](https://github.com/ErosZy/md/tree/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1)(2020-03-11) 4 | * [深入浅出动态化SSR服务(一)](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1%EF%BC%88%E4%B8%80%EF%BC%89.md) 5 | * [深入浅出动态化SSR服务(二)](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1%EF%BC%88%E4%BA%8C%EF%BC%89.md) 6 | * [深入浅出动态化SSR服务(三)](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BA%E5%8A%A8%E6%80%81%E5%8C%96SSR%E6%9C%8D%E5%8A%A1%EF%BC%88%E4%B8%89%EF%BC%89.md) 7 | * [InfoQ - WebAssembly专栏](https://github.com/ErosZy/md/tree/master/WebAssembly%E4%B8%93%E6%A0%8F)(2020-11-16) 8 | * [1. 浅述 WebAssembly 历史](https://github.com/ErosZy/md/blob/master/WebAssembly%E4%B8%93%E6%A0%8F/1.%E6%B5%85%E8%BF%B0WebAssembly%E5%8E%86%E5%8F%B2.md) 9 | * [2. Emscripten使用入门](https://github.com/ErosZy/md/blob/master/WebAssembly%E4%B8%93%E6%A0%8F/2.Emscripten%E4%BD%BF%E7%94%A8%E5%85%A5%E9%97%A8.md) 10 | * [3. 打造基于 WASM 的高性能安全沙盒](https://github.com/ErosZy/md/blob/master/WebAssembly%E4%B8%93%E6%A0%8F/3.%20%E6%89%93%E9%80%A0%E5%9F%BA%E4%BA%8EWASM%E7%9A%84%E9%AB%98%E6%80%A7%E8%83%BD%E5%AE%89%E5%85%A8%E6%B2%99%E7%9B%92.md) 11 | * [前端之巅公众号 - 深入浅出V8优化](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BAV8%E4%BC%98%E5%8C%96)(2022-06-18) 12 | * [1. Smi和HeapNumber](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BAV8%E4%BC%98%E5%8C%96/1.%20Smi%E5%92%8CHeapNumber.md) 13 | * [2. 字符串的优化](https://github.com/ErosZy/md/blob/master/%E6%B7%B1%E5%85%A5%E6%B5%85%E5%87%BAV8%E4%BC%98%E5%8C%96/2.%20%E5%AD%97%E7%AC%A6%E4%B8%B2%E7%9A%84%E4%BC%98%E5%8C%96.md) 14 | 15 | 16 | ### 文章协议 17 | 本作品采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。 18 |
19 |
20 | 21 | 知识共享许可协议 22 | 23 | -------------------------------------------------------------------------------- /WebAssembly专栏/1.浅述WebAssembly历史.md: -------------------------------------------------------------------------------- 1 | 浅谈WebAssembly历史 2 | --- 3 | 4 | > 感谢Emscripten核心作者Alon Zakai在我写作此篇文章时对邮件所提问题的耐心解答和帮助,如此才使得我能够更全面、详细及正确地还原有关WebAssembly的技术演变历程。 5 | 6 | WebAssembly无疑是近年来让人最为兴奋的新技术之一,它虽始于浏览器但已经开始不断地被各个语言及平台所集成。在实际的工业化落地上,区块链、边缘计算、游戏及图像视频等多个领域依靠WebAssembly都创造了让人称赞的产品。WebAssembly技术本身具有非常多的优点,其中最为被人所熟知的三点有: 7 | * 二进制格式 8 | * Low-Level的编译目标 9 | * 接近Native的执行效率 10 | 11 | 那么WebAssembly是从何演变而来,它为什么具有这些优点与特性并且是如何被标准化的,更重要的是作为普通开发者我们应如何更好的入手开发及实践WebAssembly呢?本专题将会将围绕WebAssembly及Emscripten工具链,通过一系列的文章依次介绍WebAssembly的演变历程、工具链使用、实践案例、最新应用场景及使用技巧,帮助普通开发者正确理解WebAssembly的使用场景,并能够正确且顺利地使用Emscripten工具链完成自己的WebAssembly相关项目。 12 | 13 | 本文作为专题的第一篇,我们将会较为详细地介绍WebAssembly的相关演变历程,以使得读者能够深入理解WebAssembly这门技术的使用场景,从而更好的学习和使用WebAssembly技术。 14 | 15 | ### JavaScript的弊端 16 | JavaScript毫无疑问是技术领域的佼佼者。自Brendan Eich于1995年花费10天时间为Netscape开发出JavaScript为始,到现在已经走过了20多个年头。随着技术的蓬勃发展,不管是NPM与GitHub上丰富的JavaScript库与框架,还是React Native、Node.js、Electron、QuickJS等领域技术的出现,无一不彰显着JavaScript生态的繁荣,JavaScript这门语言也变得越来越流行和重要。 17 | 18 | 但与此同时,随着各类应用功能的复杂化,受限于JavaScript语言本身动态类型和解释执行的设计,其性能问题也逐渐凸现。我们厄待需要新的技术帮助我们解决JavaScript的性能问题。在2008年底,Google、Apple、Mozilla为JavaScript引入了JIT(Just-In-Time)引擎,试图解决JavaScript的性能问题,并取得了非常好的效果。其中的佼佼者非Google的V8莫属,其大举提升了JavaScript的性能,并拉开了JavaScript引擎竞速的序幕。 19 | 20 | 那JIT(Just-In-Time)引擎是如何提升JavaScript的性能的呢?我们知道,由于JavaScript是解释型语言,因此JavaScript引擎需要逐行对JavaScript代码进行翻译变为可执行的代码。可执行代码有多种形式,其中较为常见的是基于AST的直接执行以及ByteCode的执行方式。显而易见,这些做法相比于直接运行机器码而言都并不高效,那么如果我们能根据代码的执行频次将部分代码实时编译为机器码,则会获得更大的性能提升,这就是JIT(Just-In-Time)的基本思路。 21 | 22 | 在实际生产中,JIT(Just-In-Time)引擎一般会引入多层次的决策来优化我们的代码: 23 | * warm阶段(解释执行的代码被执行多次): 将解释执行的代码发送给JIT(Just-In-Time)引擎,并创建出编译为机器码的执行代码,但此处并不进行替换; 24 | * hot阶段(解释执行的代码被执行得十分频繁): 解释执行代码被替换为warm阶段的机器码执行代码; 25 | * very hot阶段:将解释执行的代码发送给优化编译器(Optimising Compiler),创建和编译出更高效的机器码的执行代码并进行替换; 26 | 27 | 假设我们的JavaScript代码中有部分代码被执行了多次,此时这部分代码会被标记为warm,同时被送往JIT(Just-In-Time)引擎进行优化。JIT(Just-In-Time)引擎此时会针对这些代码进行逐行的机器码编译,然后存储在一张表的单元中(实际上表单元仅指向了被编译的机器码)。当解释执行的代码被执行得非常频繁时变会进入hot阶段,JIT(Just-In-Time)引擎会将解释执行的代码直接替换为编译的机器码版本。 28 | 29 | 需要注意的是,表单元的引用依据实际上会依赖于行号以及参数类型,假设我们有如下的代码: 30 | ```javascript 31 | function doSomething(value){ 32 | // performing some operations 33 | } 34 | 35 | const arr = [0, "String"]; 36 | for (let i = 0; i < arr.length; i++) { 37 | doSomething(arr[i]) 38 | } 39 | ``` 40 | 由于数组arr中存在两种数据类型(Number/String),当我们多次执行相关代码时,`doSomething`函数会被JIT(Just-In-Time)引擎创建并编译出两个不同类型的机器码执行代码版本,并且使用不同的表单元进行引用。当然,由于机器码执行代码的创建和编译是存在代价的,因此不同的JIT(Just-In-Time)引擎会有不同的优化策略。 41 | 42 | 如果部分代码执行得异乎频繁,那么自然的这部分解释执行的代码会被发送给优化编译器(Optimising Compiler)进行更高程度的优化,从而创建并编译出相比warm阶段更高效的机器码执行代码版本。与此同时,在创建这些高度优化的机器码执行代码期间,编译器将会严格限制执行代码的适用类型(比如仅适用于Number/String或某些特定类型参数),并且在每次调用执行前都会进行参数类型的检查,如果匹配则会使用这些高度优化的机器码执行代码,否则将会回退到warm阶段生成的机器码执行代码或是直接解释执行。 43 | 44 | 当JavaScript有了JIT(Just-In-Time)后就能高枕无忧了么?不尽然。从上面的介绍中我们可以看到,JIT(Just-In-Time)引擎的优化并非是完全无代价的。同时由于JavaScript自身的灵活性,如果我们编写JavaScript代码时并没有将数据类型严格固定,那么JIT(Just-In-Time)的效果将会被大打折扣。在Google V8团队的[《JIT-less V8》](https://v8.dev/blog/jitless)文章中我们可以看到,使用JIT-less模式的V8在运行Youtube的Living Room页面时,其测试成绩与使用JIT的V8实际差距仅为6%。这个测试侧面反应了JIT在生产中并不是完全的“性能银弹”。 45 | 46 |
47 | 48 |

JIT-less模式下V8与基线的对比

49 |
50 | 51 | 那么JavaScript能变得更快么,还是说我们需要其他技术来解决JavaScript的性能问题?此时NaCl和PNaCl应运而生。 52 | 53 | ### NaCl与PNaCl 54 | 55 | 尽管JavaScript由于JIT的加入在性能上有了很大的提升,但在许多性能敏感的领域,JavaScript仍旧无法满足我们的需求,因此在2008年,Google的Brad Chen、Bennet Yee以及David Sehr开源了NaCl技术,并在2009年正式达到生产可用的状态。NaCl全称为"Native Client",其由C/C++语言编写并定义了一套Native Code的安全子集(SFI技术),并执行于自己独立的沙盒环境之中,以防止安全性未知的C/C++代码对操作系统本身产生危害。 56 | 57 | NaCl应用及其模块在性能上与原生应用的差距非常小,但由于NaCl与CPU架构强关联不具有可移植性,因此需要针对不同的平台进行开发以及编译,这导致了我们无法自由分发NaCl应用及其模块。为了解决这个问题,NaCl改进技术PNaCl出现了。 58 | 59 |
60 | 61 |

NaCl的性能损耗极小

62 |
63 | 64 | PNaCl的全称为"Portable Native Client",其通过替换Native Code为LLVM IR子集并在客户端编译为NaCl的方式解决了NaCl的分发问题。PNaCl不依赖于特定的CPU架构,更易于被部署和使用,“一次编译,到处运行”在PNaCl上得到了实现。但同样的,PNaCl也是运行在自己的独立沙盒之中,其无法直接的访问Web APIs,而是需要通过一个名为"PPAPI"的接口来与JavaScript进行通信。 65 | 66 | PNaCl技术在当时看起来是一个非常理想的方案,其兼具高性能和易于分发的特点,但实际上其在当时并没有受到非常强的支持。PPAPI出现的时代正好是处于人们尽可能试图摆脱Flash、Java Applet等插件的时代,尽管当时Chrome已经将NaCl与PNaCl直接集成,但其运行在独立沙盒环境与使用独立API的方式与Flash、Java Applet等插件非常类似。同时,其开发难度和成本以及糟糕的兼容性问题(2011年开始Firefox及Opera正式支持PPAPI及NaCl)都成为了NaCl/PNaCl普及的最大障碍。 67 | 68 | ### 让人惊艳的asm.js 69 | 70 | 谈到asm.js和WebAssembly就不得不提到其中关键人物Alon Zakai。在2010年,Alon Zakai结束了两年的创业项目,加入了Mozilla负责Android版Firefox的开发。在Mozilla的工作之余,Alon Zakai继续编写着自己的C/C++游戏引擎,在项目临近尾声之时,Alon Zakai突发奇想,想将自己的C/C++游戏引擎运行在浏览器上。在2010年,NaCl还是一门非常新的技术,而PNaCl才刚刚开始开发,此时并没有一个非常好的技术方案能够将Alon的C/C++游戏引擎跑在浏览器上。但好在C/C++是强类型语言,而JavaScript是弱类型语言,将C/C++代码编译为JavaScript代码在技术实现上是完全可行的。于是Alon Zakai自此开始编写相关的Compiler实现,`Emscripten(LLVM into JavaScript)`由此诞生了! 71 | 72 |
73 | 74 |
75 | 76 | 到2011年,Emscripten已经具备编译像Python以及DOOM等中大型项目的能力,与此同时Emscripten也在JSConfEU会议上首次亮相,并取得了一定的影响力。Mozilla看到了Emscripten项目的巨大潜力(相较于NaCl而言更加的Web友好),Brendan及Andreas邀请Alon加入Mozilla的Research团队全职负责Emscripten项目的开发,Alon Zakai欣然接受并将工作的重心放在了如何提升Emscripten编译的JavaScript代码执行速度上。 77 | 78 | 在`JavaScript的弊端`章节中我们可以看到,尽管JavaScript拥有JIT(Just-In-Time),但由于JavaScript本身的语言特性,导致JIT(Just-In-Time)难以被预测,在实际的生产环境当中JIT(Just-In-Time)的效果往往并没有那么显著。为了使得JavaScript运行得更快,我们应该要更充分的利用JIT(Just-In-Time),因此在2013年,Alon Zakai联合Luke Wagner,David Herman发布了asm.js。 79 | 80 | asm.js的思想很简单,就是尽可能的明确对应的类型,以便JIT(Just-In-Time)能够被充分利用。如下图示例所示: 81 | 82 |
83 | 84 |
85 | 86 | 我们可以看到,对于`add`函数而言,由于传入参数`x`,`y`以及返回值进行了`|0`的操作,那么其能够很明确为JIT(Just-In-Time)指明对应的类型(i32),因此能被JIT(Just-In-Time)充分优化(不考虑后期AOT的情况)。通过添加类似的类型注解,Emscripten编译的asm.js在运行速度上相比普通JavaScript有了质的飞跃,在Benchmark中,asm.js能达到Native性能的50%左右,相比于普通的JavaScript代码而言取得了极大的性能提升,这无疑是让人兴奋的成果,但是asm.js自身也存在一些无法忽视的问题,其总体而言并不是一个非常理想的技术方案。 87 | 88 |
89 | 90 |
91 | 92 | 最显而易见的就是asm.js代码的"慢启动"问题,由于asm.js还是和JavaScript一样的文本格式,因此对于大中型项目而言,其解析所花费的时间会非常长,无法与高效的二进制格式相提并论。其次,asm.js实质上是一种较为hack的实现方式,类似`|0`的类型标注不具有可读性,同时拓展asm.js也变得越来越复杂且不可靠:随着asm.js想要更加接近于Native的执行性能,不免会对诸多Math函数(例如Math.imul及Math.fround等)进行拓展和改写,从长远来看这对TC39标准的制定是不友好的,同时asm.js自身的相关实现(例如memory growth等)也遭遇了非常多的问题而被迫不断修订asm.js标准。"The hacks had a cost",因此我们需要一个全新的技术来解决asm.js所遇到的这些问题。 93 | 94 | ### 合作共赢 - WebAssembly 95 | 96 | 在2013年,NaCl/PNaCl与asm.js/Emscripten形成了不同路线发展的竞争态势,但与此同时,Google及Mozilla也在工具及虚拟机层面加强了许多合作,其中包括: 97 | * 由Google的JF Bastien牵头的每月Google和Mozilla工具团队之间的交流会; 98 | * Emscripten和PNaCl开始互相共享部分代码,包括Legalization Passes、le32 triple等; 99 | * 尝试将NaCl应用通过Emscripten进行编译,并开源Pepper.js; 100 | * Google及Mozilla共同就asm.js进行代码贡献并规划未来Native Code在Web上的合理方案; 101 | * 就WebAssembly前身"WebAsm"进行标准和方案的讨论; 102 | 103 | 最终在2015年的4月1号,"WebAssembly"击败了"WebAsm"、"WebMachine"以及其它名称在Google和Mozilla的团队交流邮件中被确定使用。至2015年6月17号,两方就WebAssembly的标准化工作进行了确定,并搭建了WebAssembly官网同时进行了对外的宣传工作。WebAssembly的设计汲取了NaCl与asm.js两者的优点: 104 | * WebAssembly并不依赖于JavaScript,与NaCl/PNaCl一样,它基于二进制格式,能够被快速的解析; 105 | * 与asm.js一样,依靠Emscripten等工具链提供的API,它以非常自然的方式直接操作Web APIs,而不用像PNaCl一样需要处理与JavaScript之间的通信; 106 | * WebAssembly依赖于LLVM IR并使用独立的VM环境,因此其它语言/平台能够以较低成本进行接入,并且能够且易于被持续优化至接近Native的性能; 107 | 108 | 目前各大主流浏览器已经完全实现了WebAssembly的MVP版本,并已被接纳为"浏览器的第二语言"。依靠优秀的设计,WebAssembly也从浏览器平台走向更多的平台,WASI(WebAssembly System Interface)将为WebAssembly提供更多的可能性。随着WebAssembly的相关标准的逐渐确定和完善,WebAssembly技术的应用领域将会越来越广。 109 | 110 | ### 最后 111 | 本文我们从JavaScript开始,介绍了NaCl/PNaCl以及asm.js技术方案的优缺点。通过简单回顾WebAssembly的相关历史背景,我们能更好地够理解WebAssembly技术的演变过程及其适用场景。在后面的文章中,我们将基于Emscripten工具链继续探讨WebAssembly,通过具体的实例介绍WebAssembly应用的基本方法和相关实现。 112 | -------------------------------------------------------------------------------- /WebAssembly专栏/2.Emscripten使用入门.md: -------------------------------------------------------------------------------- 1 | Emscripten使用入门 2 | ---- 3 | 4 | 在上一篇中我们较为详细的讲述了WebAssembly的演变历程,通过WebAssembly的演变历程我们对WebAssembly的三个优点(二进制格式、Low-Level的编译目标、接近Native的执行效率)也有了比较深刻的理解。在本章中我们将选取Emscripten及C/C++语言来简要讲述WebAssembly相关工具链的使用,通过较为简单的例子帮助大家能够更为快速上手WebAssembly相关的应用开发。请放心,在本章中我们将避免复杂难懂的C/C++语言技巧,力求相关示例简单、直接、易懂。如果你有Rust、Golang等支持WebAssembly的相关语言背景,那么可以将本章相关内容作为参考,与对应官方工具链进行结合学习。 5 | 6 | ### 关于Emscripten 7 | 8 | Emscripten是WebAssembly工具链里重要的组成部分。从最为简单的理解来说,Emscripten能够帮助我们将C/C++代码编译为ASM.js以及WebAssembly代码,同时帮助我们生成部分所需的JavaScript胶水代码。但实质上Emscripten与LLVM工具链相当接近,其包含了各种我们开发所需的C/C++头文件、宏参数以及相关命令行工具。通过这些C/C++头文件及宏参数,其可以指示Emscripten为源代码提供合适的编译流程并完成数据转换,如下图所示: 9 | 10 |
11 | 12 |

Emscripten编译流程(来自官网)

13 |
14 | 15 | emcc是整个工具链的编译器入口,其能够将C/C++代码转换为所需要的LLVM-IR代码,Clang/LLVM(Fastcomp)能够将通过emcc生成的LLVM-IR代码转换为ASM.js及WebAssembly代码,而emsdk及.emscripten文件主要是用来帮助我们管理工具链内部的不同版本的子集工具及依赖关系以及相关的用户编译设置。 16 | 17 | 在我们的日常业务开发过程中,实际上并不需要太过关心Emscripten内部的实现细节,Emscripten已经非常成熟且易于使用。但相关读者若想知道Emscripten内部的更多细节,可以访问Emscripten官网以及Github阅读相关WIKI进行了解。 18 | 19 | ### 下载、安装与配置 20 | 21 | 在进行相关操作之前,请先确保已经安装git工具并能够使用基本的git命令,接下来我们以Linux系统下的操作作为示例演示如何下载、安装及配置Emscripten。若你的操作系统为Windows或是OSX等其他系统,请参考官方文档中的相关章节进行操作。 22 | 23 | * 安装 24 | 25 | 进入你自己的安装目录,执行如下命令获取到Emscripten SDK Manager(emsdk): 26 | ```shell 27 | > git clone https://github.com/emscripten-core/emsdk.git 28 | ``` 29 | 30 | * 下载 31 | 进入emsdk目录,并执行如下的命令进行安装操作: 32 | ```shell 33 | > cd emsdk 34 | > git pull 35 | > ./emsdk install latest 36 | ``` 37 | 需要注意的是,install命令可以安装特定版本的Emscripten开发包及其依赖的所有自己工具,例如: 38 | ```shell 39 | > ./emsdk install 1.38.45 40 | ``` 41 | 42 | * 激活及配置 43 | 当安装完成后,我们可以通过如下命令进行Emscripten的激活和配置: 44 | ```shell 45 | > ./emsdk activate latest # or ./emsdk activate 1.38.45 46 | > source ./emsdk_env.sh 47 | ``` 48 | 49 | 现在让我们执行 `emcc -v` 命令查看相关的信息,若正确输出如下类似信息则说明Emscripten安装及配置成功。 50 | 51 |
52 | 53 |

emcc -v的相关信息输出

54 |
55 | 56 | ### 小试身手 57 | 终于进入有趣的部分了,按照惯例,我们先以打印"Hello World!"作为我们学习WebAssembly的第一个程序吧!让我们先快速编写一个C/C++的打印"Hello World!"代码,如下所示: 58 | ```C++ 59 | #include 60 | 61 | int main() { 62 | printf("Hello World!\n"); 63 | return 0; 64 | } 65 | ``` 66 | 67 | 这个程序很简单,使用相关的GCC等相关编译器能够很正确得到对应的输出。那么如何产出WebAssembly的程序呢?依靠Emscripten整个操作也非常简单: 68 | ```shell 69 | > emcc main.c -o hello.html 70 | ``` 71 | 执行完毕后你将得到三个文件代码,分别是: 72 | * hello.html 73 | * hello.js:相关的胶水代码,包括加载WASM文件并执行调用等相关逻辑 74 | * hello.wasm:编译得到的核心WebAssembly执行文件 75 | 76 | 接着我们在当前目录启动一个静态服务器程序(例如NPM中的static-server),然后访问hello.html后我们就能看到"Hello World!"在页面上正确输出了!当然,实际上hello.html文件并不是一定需要的,如果我们想要让NodeJS使用我们代码,那么直接执行: 77 | ```shell 78 | > emcc main.c 79 | ``` 80 | 即可得到 `a.out.js` 及 `a.out.wasm` 两个文件,然后我们使用NodeJS执行: 81 | ```shell 82 | > node a.out.js 83 | ``` 84 | 也能正确的得到对应的输出(你可以自行创建html文件并引入 `a.out.js`进行浏览器环境的执行 )。 85 | 86 | 当然,在我们的日常的业务开发中相关程序是不可能如此简单的。除了我们自己的操作逻辑外,我们还会依赖于非常多商用或开源的第三方库及框架。比如在数据通信及交换中我们往往会使用到JSON这种轻量的数据格式。在C/C++中有非常多相关的开源库能解决JSON解析的问题,例如`cJSON`等,那么接下来我们就增加一点点复杂度,结合 `cJSON` 库编一个简单的JSON解析的程序。 87 | 88 | 首先我们从Github中找到 `cJSON` 的主页,然后下载相关的源码放置在我们项目的vendor文件夹中。接着我们在当前项目的根目录下创建一个`CMakeList.txt`文件,并填入如下内容: 89 | 90 | ```CMake 91 | cmake_minimum_required(VERSION 3.15) # 根据你的需求进行修改 92 | project(sample C) 93 | 94 | set(CMAKE_C_STANDARD 11) # 根据你的C编译器支持情况进行修改 95 | set(CMAKE_EXECUTABLE_SUFFIX ".html") # 编译生成.html 96 | 97 | include_directories(vendor) # 使得我们能引用第三方库的头文件 98 | add_subdirectory(vendor/cJSON) 99 | 100 | add_executable(sample main.c) 101 | 102 | # 设置Emscripten的编译链接参数,我们等等会讲到一些常用参数 103 | set_target_properties(sample PROPERTIES LINK_FLAGS "-s EXIT_RUNTIME=1") 104 | target_link_libraries(sample cjson) # 将第三方库与主程序进行链接 105 | ``` 106 | 107 | 那什么是 `CMakeList.txt` 呢?简单来说,`CMakeList.txt` 是 `CMake` 的“配置文件”,`CMake` 会根据 `CMakeList.txt` 的内容帮助我们生成跨平台的编译命令。在我们现在及之后的文章中,不会涉及非常复杂的 `CMake` 的使用,你完全可以把 `CMakeList.txt` 里的相关内容当成固定配置提供给多个项目的复用,如若需要更深入的了解 `CMake` 的使用,可以参考 `CMake` 的官网教程及文档。好了,现在让我们在代码中引入 `cJSON` 然后并使用它进行JSON的解析操作,代码如下: 108 | 109 | ```C++ 110 | #include 111 | #include "cJSON/cJSON.h" 112 | 113 | int main() { 114 | const char jsonstr[] = "{\"data\":\"Hello World!\"}"; 115 | cJSON *json = cJSON_Parse(jsonstr); 116 | 117 | const cJSON *data = cJSON_GetObjectItem(json, "data"); 118 | printf("%s\n", cJSON_GetStringValue(data)); 119 | 120 | cJSON_Delete(json); 121 | return 0; 122 | } 123 | ``` 124 | 125 | 代码的整体逻辑非常简单易懂,在这里就不再赘述。由于我们使用了 `CMake`,因此Emscripten的编译命令需要有一点点修改,我们将不使用emcc而是使用emcmake及emmake来创建我们的相关WebAssembly代码,命令如下: 126 | ```shell 127 | > mkdir build 128 | > cd build 129 | > emcmake cmake .. 130 | > emmake make 131 | ``` 132 | 133 | 我们创建了一个build文件夹用来存放cmake相关的生成文件及信息,接着进入build文件夹并使用emcmake及emmake命令生成对应的WebAssembly代码sample.html、sample.js、sample.wasm,最后我们执行访问sample.html后可以看到其正确的输出了JSON的data内容。 134 | 135 | > 如若你从未使用过CMake,请不要为CMake的相关内容因不理解而产生沮丧或者畏难情绪。在我的日常的WebAssembly开发中,基本都是沿用一套 `CMakeList.txt` 并进行增删改,与此同时编译流程基本与上诉内容一致,你完全可以将这些内容复制在你的备忘录里,下次需要用到时直接修改即可。 136 | 137 | ### WebAssembly的调试 138 | 139 | 对于开发的WebAssembly代码而言,我们对于调试可以使用两种方式,一种方式是通过日志的方式进行输出,另一种方式使用单步调试。使用日志的方式输出调试信息非常容易,Emscripten能很好的支持C/C++里面的相关IO库。而对于单步调试而言,目前最新版本的Firefox及Chrome浏览器都已经有了一定的支持,例如我们有如下代码: 140 | 141 | ```C++ 142 | #include 143 | 144 | int main() { 145 | printf("Hello World!"); 146 | return 0; 147 | } 148 | ``` 149 | 150 | 然后我们使用emcc进行编译得到相关的文件: 151 | ```shell 152 | > emcc -g4 main.c -o main.wasm # -g4可生成对应的sourcemap信息 153 | ``` 154 | 接着打开Chrome及其开发者工具,我们就可以看到对应的main.c文件并进行单步调试了。 155 | 156 |
157 | 158 |

使用Chrome进行单步调试

159 |
160 | 161 | 但值得注意的是,目前emcmake对于soucemap的生成支持并不是很好,并且浏览器的单步调试支持也仅仅支持了代码层面的映射关系,对于比较复杂的应用来说目前的单步调试能力还比较不可用,因此建议开发时还是以日志调试为主要手段。 162 | 163 | ### JavaScript调用WebAssembly 164 | 165 | 对于WebAssembly项目而言,我们经常会需要接收外部JavaScript传递的相关数据,难免就会涉及到互操作的问题。回到最开始的JSON解析例子,我们一般情况而言是需要从外部JavaScript中获取到JSON字符串,然后在WebAssembly代码中进行解析后做对应的业务逻辑处理,并返回对应的结果给外部JavaScript。接下来,我们会增强JSON解析的相关代码,实现如下: 166 | ```C++ 167 | #include 168 | #include "cJSON/cJSON.h" 169 | 170 | int json_parse(const char *jsonstr) { 171 | cJSON *json = cJSON_Parse(jsonstr); 172 | const cJSON *data = cJSON_GetObjectItem(json, "data"); 173 | printf("%s\n", cJSON_GetStringValue(data)); 174 | cJSON_Delete(json); 175 | return 0; 176 | } 177 | ``` 178 | 179 | 在如上代码中,我们将相关逻辑封装在 `json_parse` 的函数之中,以便外部JavaScript能够顺利的调用得到此方法,接着我们修改一下 `CMakeList.txt` 的编译链接参数: 180 | ```CMake 181 | #.... 182 | set_target_properties(sample PROPERTIES LINK_FLAGS "\ 183 | -s EXIT_RUNTIME=1 \ 184 | -s EXPORTED_FUNCTIONS=\"['_json_parse']\"\ 185 | ") 186 | ``` 187 | EXPORTED_FUNCTIONS配置用于设置需要暴露的执行函数,其接受一个数组。这里我们需要将 `json_parse` 进行暴露,因此只需要填写 `_json_parse`即可。需要注意的是,这里暴露的函数方法名前面以下划线(_)开头。然后我们执行emcmake编译即可得到对应的生成文件。 188 | 189 | 接着我们访问sample.html,并在控制台执行如下代码完成JavaScript到WebAssembly的调用: 190 | ```JavaScript 191 | let jsonstr = JSON.stringify({data:"Hello World!"}); 192 | jsonstr = intArrayFromString(jsonstr).concat(0); 193 | 194 | const ptr = Module._malloc(jsonstr.length); 195 | Module.HEAPU8.set(jsonstr, ptr); 196 | Module._json_parse(ptr); 197 | ``` 198 | 在这里,`intArrayFromString`、`Module._malloc` 以及 `Module.HEAPU8` 等都是Emscripten提供给我们的方法。 `intArrayFromString` 会将字符串转化成UTF8的字符串数组,由于我们知道C/C++中的字符串是需要 `\0` 结尾的,因此我们在末尾concat了一个0作为字符串的结尾符。接着,我们使用 `Module._malloc` 创建了一块堆内存并使用 `Module.HEAPU8.set` 方法将字符串数组赋值给这块内存,最后我们调用 `_json_parse` 函数即可完成WebAssembly的调用。 199 | 200 | 需要注意的是,由于WebAssembly端的C/C++代码接收的是指针,因此你是不能够将JavaScript的字符串直接传给WebAssembly的。但如果你传递的是int、float等基本类型,那么就可以直接进行传递操作。当然,上面的代码我们还可以进一步简化为: 201 | ```javascript 202 | const jsonstr = JSON.stringify({data:"Hello World!"}); 203 | const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL); 204 | Module._json_parse(ptr); 205 | ``` 206 | 207 | 总而言之,如果是基本类型,例如int、float、double等,那么你可以直接进行传值调用,但如果是数组、指针等类型,你就需要依靠 `Module` 对象的方法开辟内存空间,传递地址信息给WebAssembly进行调用。 208 | 209 | ### WebAssembly调用JavaScript 210 | 211 | WebAssembly在执行完成之后可能会需要返回部分返回值,针对这个场景其也分为两种情况: 212 | * 如果返回int、float、double等基础类型,那么直接函数声明返回类型后返回即可; 213 | * 如果需要返回数组、指针等类型,则可以通过 `EM_ASM` 或是 `Memory Copy` 的方式进行处理; 214 | 215 | 例如我们在WebAssembly端接收并解析JSON字符串后,判断对应数值然后返回修改后的JSON字符串,这个需求我们采用 `EM_ASM` 方式的代码如下: 216 | ```C++ 217 | #include 218 | #include "cJSON/cJSON.h" 219 | #ifdef __EMSCRIPTEN__ 220 | #include 221 | #endif 222 | 223 | int json_parse(const char *jsonstr) { 224 | cJSON *json = cJSON_Parse(jsonstr); 225 | cJSON *data = cJSON_GetObjectItem(json, "data"); 226 | cJSON_SetValuestring(data, "Hi!"); 227 | 228 | const char *result = cJSON_Print(json); 229 | #ifdef __EMSCRIPTEN__ 230 | EM_ASM({ 231 | if(typeof window.onRspHandler == "function"){ 232 | window.onRspHandler(UTF8ToString($0)) 233 | } 234 | }, result); 235 | #endif 236 | 237 | cJSON_Delete(json); 238 | return 0; 239 | } 240 | ``` 241 | 首先我们引入emscripten.h头文件,接着我们使用 `EM_ASM` 调用外部的 `window.onRspHandler` 回调方法即可完成对应需求。`EM_ASM` 大括号内可以书写任意的JavaScript代码,并且可以对其进行传参操作。在本例中,我们将result传递给 `EM_ASM` 方法,其 `$0` 为传参的等价替换,若还有更多参数则可以写为 `$1`、`$2`等。接着,我们编译对应代码,然后访问sample.html,并在控制台执行如下代码完成JavaScript到WebAssembly的调用: 242 | 243 | ```javascript 244 | window.onRspHandler = (result) => { 245 | console.log(result); // output: {"data":"Hi!"} 246 | }; 247 | 248 | const jsonstr = JSON.stringify({data:"Hello World!"}); 249 | const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL); 250 | Module._json_parse(ptr); 251 | ``` 252 | 253 | 可以看到,`window.onRspHandler` 函数被调用并正确的进行了结果输出。实际上Emscripten给我们提供了非常多的JavaScript调用函数及宏,包括: 254 | * EM_ASM 255 | * EM_ASM_INT 256 | * emscripten_run_script 257 | * emscripten_run_script_int 258 | * emscripten_run_script_string 259 | * emscripten_async_run_script 260 | * ....... 261 | 262 | 在实际使用中我们推荐使用 `EM_ASM` 相关宏,若需要了解其中的差异可以参考Emscripten官网相关章节。那么如果我们使用 `Memory Copy` 的话,代码如下: 263 | 264 | ```C++ 265 | #include 266 | #include 267 | #include 268 | #include "cJSON/cJSON.h" 269 | 270 | int json_parse(const char *jsonstr, char *output) { 271 | cJSON *json = cJSON_Parse(jsonstr); 272 | cJSON *data = cJSON_GetObjectItem(json, "data"); 273 | cJSON_SetValuestring(data, "Hi!"); 274 | 275 | const char *string = cJSON_Print(json); 276 | memcpy(output, string, strlen(string)); 277 | 278 | cJSON_Delete(json); 279 | return 0; 280 | } 281 | ``` 282 | 283 | 我们相比之前的实现多传递了一个参数output,在WebAssembly端解析、改写JSON完成后,使用memcpy将对应结果复制到output当中。接着,我们编译对应代码,然后访问sample.html,并在控制台执行如下代码完成JavaScript到WebAssembly的调用: 284 | ```javascript 285 | const jsonstr = JSON.stringify({data:"Hello World!"}); 286 | const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL); 287 | 288 | const output = Module._malloc(1024); 289 | Module._json_parse(ptr, output); 290 | console.log(UTF8ToString(output)); // output: {"data":"Hi!"} 291 | ``` 292 | 293 | 如上所示,我们使用 `Malloc._malloc` 创建了一块堆内存,并传递给 `_json_parse` 函数,同时使用 `UTF8ToString` 方法将对应JSON字符串结果输出。 294 | 295 | ### 使用更多的Emscripten的API 296 | 297 | 实际上Emscripten为了方便我们在C/C++中编写代码,其提供了非常多的API供我们使用,其中包括:Fetch、File System、VR、HTML5、WebSocket等诸多实现。例如我们以Fetch为例: 298 | ```C++ 299 | #include 300 | #include 301 | 302 | #ifdef __EMSCRIPTEN__ 303 | #include 304 | void downloadSucceeded(emscripten_fetch_t *fetch) { 305 | printf("%llu %s.\n", fetch->numBytes, fetch->url); 306 | emscripten_fetch_close(fetch); 307 | } 308 | 309 | void downloadFailed(emscripten_fetch_t *fetch) { 310 | emscripten_fetch_close(fetch); 311 | } 312 | #endif 313 | 314 | int main() { 315 | #ifdef __EMSCRIPTEN__ 316 | emscripten_fetch_attr_t attr; 317 | emscripten_fetch_attr_init(&attr); 318 | strcpy(attr.requestMethod, "GET"); 319 | attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; 320 | attr.onsuccess = downloadSucceeded; 321 | attr.onerror = downloadFailed; 322 | emscripten_fetch(&attr, "http://myip.ipip.net/"); 323 | #endif 324 | } 325 | ``` 326 | 在上面的代码中我们使用了 `emscripten_fetch` 相关函数来进行浏览器宿主环境fetch方法的调用。为了启用Emscripten中的Fetch能力,我们还需要修改编译链接参数,为其增加-s FETCH=1: 327 | ```CMake 328 | #.... 329 | set_target_properties(sample PROPERTIES LINK_FLAGS "\ 330 | -s NO_EXIT_RUNTIME=1 \ 331 | -s FETCH=1 \ 332 | ") 333 | ``` 334 | 想要了解更多的可用API及细节,你可以访问Emscripten官网阅读API Reference相关章节。 335 | 336 | ### 编译链接参数 337 | 338 | 在上面实践中我们使用了一些编译连接的参数,包括: 339 | * -g 340 | * -s EXIT_RUNTIME 341 | * -s EXPORTED_FUNCTIONS 342 | * -s FETCH 343 | * -s NO_EXIT_RUNTIME 344 | 345 | 实际上,Emscripten包含了非常丰富的相关设置参数帮助我们在编译和链接时优化我们的代码。其中部分常用的参数包括: 346 | 347 | * -O1、-O2、-O3、-Oz、-Os、-g等:编译优化,具体可参考Emscripten官网相关章节; 348 | * -s ENVIRONMENT:设定编译代码的可执行环境,默认值为"web,work,node"; 349 | * -s SINGLE_FILE:是否将ASM.js或WebAssembly代码以Base64的方式嵌入到JavaScript胶水代码中,可取值0/1; 350 | * -s WASM:是否编译为WebAssembly代码,0编译为ASM.js,1编译为WebAssembly; 351 | * -s FETCH:是否启用Fetch模块,可取值0/1; 352 | * -s DISABLE_EXCEPTION_CATCHING:禁止生成异常捕获代码,可取值0/1; 353 | * -s ERROR_ON_UNDEFINED_SYMBOLS:编译时出现Undefined Symbols后是否退出,可取值0/1; 354 | * -s EXIT_RUNTIME: 执行完毕 `main` 函数后是否退出,可取值0/1; 355 | * -s FILESYSTEM:是否启用File System模块,可取值0/1; 356 | * -s INVOKE_RUN:是否执行C/C++的`main`函数,可取值0/1; 357 | * -s ASSERTIONS:是否给运行时增加断言,可取值0/1; 358 | * -s TOTAL_MEMORY:总的可用内存使用数,可取以16777216为基数的整数值; 359 | * -s ALLOW_MEMORY_GROWTH:当可用内存不足时,是否自动增长,可取值0/1; 360 | * -s EXPORTED_FUNCTIONS:暴露的函数列表名称; 361 | * -s LEGACY_VM_SUPPORT:是否增加部分兼容函数以兼容低版本浏览器(iOS9、老版本Chrome等),可取值0/1; 362 | * -s MEM_INIT_METHOD:是否将.mem文件以Base64的方式嵌入到JavaScript胶水代码中,可取值0/1; 363 | * -s ELIMINATE_DUPLICATE_FUNCTIONS:将重复函数进行自动剔除,可取值0/1; 364 | * --closure: 是否使用Google Closure进行最终代码的压缩,可取值0/1; 365 | * --llvm-lto:是否进行LLVM的链接时优化,可取值0-3; 366 | * --memory-init-file:同-s MEM_INIT_METHOD; 367 | * ...... 368 | 369 | 更多编译链接参数设置可以参考 `src/settings.js` 文件。 370 | 371 | ### 总结 372 | 在本章中我们较为详细的介绍了Emscripten的入门使用,关于Emscripten的更多内容(代码性能及体积优化、API使用等)可以参考Emscripten官网或Github的WIKI。在接下来的文章中,我们会以具体需求实例为入口,帮助大家能够更好的学习Emscripten在实际生产中的使用。 373 | -------------------------------------------------------------------------------- /WebAssembly专栏/3. 打造基于WASM的高性能安全沙盒.md: -------------------------------------------------------------------------------- 1 | 打造基于WASM的高性能安全沙盒 2 | ---- 3 | 4 | 通过上一篇文章对 Emscripten 的使用介绍,大家应该具备一定的使用 Emscripten 工具链开发相关 WebAssembly 项目的基础能力了。在本文中,我们将继续通过具体案例更深入地了解 Emscripten 的使用技巧,同时加强对 WebAssembly 二进制格式、Low-Level 编译目标及接近 Native 执行效率的理解。 5 | 6 | ### 前端核心数据加密 7 | 8 | Web 技术的开放以及便捷带来了其极高速的发展,但自作者从事 Web 前端相关开发工作以来,并没有听到太多关于前端核心数据加密的方案,因此“前端数据无加密”慢慢的也变成了业界的一个共识。但在某些日常开发的场景中,我们又会涉及到相当强度的前端核心数据加密的问题,特别是需要在于后端的数据通信上面(包括 HTTP、HTTPS 以及 WebSocket 的数据交换),例如前端日志数据加密就是一个典型的场景。 9 | 10 | 11 | 针对于此类需求,在以往的实践中我们通常使用纯 JavaScript 实现的 Crypto 库来对相关数据加密,同时使用 UglifyJS、Google Closure Compiler 等混淆器进行相关加密代码的混淆,以增加对应的破译难度。但在目前的实践之中由于浏览器自身的调试工具逐渐方便且强大,因此此类方法的安全性变得越来越脆弱。 12 | 13 | 14 | 考虑到这种情况,越来越多的公司开始将前端核心数据加密的相关逻辑迁移至 WebAssembly,依靠 WebAssembly 在整个过程中的编译及二进制特性来提高对应数据加密逻辑的安全性,以此保障业务安全。对于这种方案而言,由于我们可以将 WebAssembly 视作一个全新的 VM,其他语言通过相关工具链(例如 Emscripten)来产出此 VM 可执行的代码,其安全性相比于单纯的 Crypto 库加 JavaScript 混淆器而言具有比较明显的优势。 15 | 16 | 17 | 现在让我们就着手实现一个非常简单的 WebAssembly 签名加密模块来进行一下对应的体验。首先,创建相关的项目,同时下载 Github 上的某 MD5 实现到本项目的 vendor 文件夹中,整体目录结构如下所示: 18 | 19 | ```shell 20 | ├main.c 21 | ├CMakeList.txt 22 | ├vendor 23 | ├─base64 24 | │ └─CMakeList.txt 25 | │ └─base64.c 26 | │ └─base64.h 27 | └─md5 28 | └─CMakeList.txt 29 | └─md5.c 30 | └─md5.h 31 | ``` 32 | 33 | 接着我们依照上一章的 CMake 文件进行稍加修改,增加对此 MD5 库的编译链接,如下所示: 34 | 35 | ```cmake 36 | cmake_minimum_required(VERSION 3.15) # 根据你的需求进行修改 37 | project(sample C) 38 | 39 | set(CMAKE_C_STANDARD 11) # 根据你的C编译器支持情况进行修改 40 | set(CMAKE_EXECUTABLE_SUFFIX ".html") # 编译生成.html 41 | 42 | include_directories(${PROJECT_SOURCE_DIR}/vendor) # 使得我们能引用第三方库的头文件 43 | add_subdirectory(${PROJECT_SOURCE_DIR}/vendor/md5) 44 | add_subdirectory(${PROJECT_SOURCE_DIR}/vendor/base64) 45 | 46 | add_executable(sample main.c) 47 | 48 | # 设置Emscripten的编译链接参数,我们等等会讲到一些常用参数 49 | set_target_properties(sample PROPERTIES LINK_FLAGS "\ 50 | -s EXIT_RUNTIME=1 \ 51 | -s EXPORTED_FUNCTIONS=\"['_sign']\" 52 | ") 53 | 54 | target_link_libraries(sample md5 base64) # 将第三方库与主程序进行链接 55 | ``` 56 | 57 | 最后实现我们的数据签名逻辑即可,代码如下: 58 | 59 | ```c++ 60 | #include 61 | #include "md5/md5.h" 62 | #include "base64/base64.h" 63 | 64 | const char* salt_key = "md5 salt key"; 65 | 66 | #ifdef __EMSCRIPTEN__ 67 | int sign(const char* msg, int len, char *output){ 68 | int data_len = strlen(salt_key) + len + 1; 69 | char *data = malloc(data_len); 70 | memset(data, 0, data_len); 71 | memcpy(data, msg, len); 72 | memcpy(data + len, salt_key, strlen(salt_key)); 73 | 74 | uint8_t result[16] = {0}; 75 | md5((uint8_t *)data, strlen(data), result); 76 | 77 | char *encode_out = malloc(BASE64_ENCODE_OUT_SIZE(16)); 78 | base64_encode(result, 16, encode_out); 79 | memcpy(output, encode_out, BASE64_ENCODE_OUT_SIZE(16)); 80 | free(encode_out); 81 | 82 | return 0; 83 | } 84 | #endif 85 | ``` 86 | 87 | 在这里我们使用了上一章介绍的Memory Copy的方式来进行结果数据的传递,因此我们的 JavaScript 代码调用应如下所示: 88 | 89 | ```javascript 90 | const str = intArrayFromString("your data here"); 91 | const ptr = allocate(str, "i8", ALLOC_NORMAL); 92 | const output = Module._malloc(1024); 93 | Module._sign(ptr, str.length, output); 94 | console.log(UTF8ToString(output)); // O6VFgoqQiF52FYyH4VmpPQ== 95 | ``` 96 | 97 | 在sign的具体实现中我们可以看到,为了增加整体的安全性,我们对其中的内容进行了“加盐”处理。这种操作看起来万无一失,但是实际上我们稍加分析是可以拿到对应的“盐”值的。现在我们将生成的 sample.wasm 文件拖入到编辑器中,然后搜索md5 salt key,我们可以很顺利的得到对应的“盐”值。 98 | 99 |
100 | 101 |

WebAssembly 无法隐藏所有信息

102 |
103 | 104 | 针对于这种情况,我们可以进一步使用异或等算法对“盐”值进行相关的处理来达到更深度的处理,但对于需要更高强度的核心数据保护的应用而言这也不过是叠加的障眼法,在这里我们需要更可靠的方式来达到我们的需求。 105 | 106 | ### 沙盒保护 107 | 108 | 沙盒保护的思路比较直接:为了更好的达到代码整体保护的目的,我们会自行创建一个完全独立的代码执行环境。在这个独立环境中,我们根据我们的需求限制内部代码使用的 built-in API 从而达到完全可控的状态。与此同时,只要我们符合执行代码的相关规范,那么我们可以自行设计相关的 Opcode 及 IR,来达到防逆向的目的。针对于前端环境而言,其整体流程如下所示: 109 | 110 |
111 | 112 |

沙盒保护的编译/运行过程

113 |
114 | 115 | 由于整个前端环境大部分都依靠于 JavaScript,因此在沙盒环境上我们有非常多的选择,在本章中我们采用 QuickJS 来进行相关的介绍和演示。如果有其他的沙盒环境需求,可以参考本章的相关思路来进行适当的修改。 116 | 117 | 118 | QuickJS 想必大家都不会陌生,其作者 Fabrice Bellard 编写了 TinyCC/TinyGL/FFmpeg/JSLinux 等多个知名项目。QuickJS 实现精小,几乎完整支持 ECMA2019,同时具有极低的启动速度有优秀的解释执行性能。在使用 QuickJS 完成我们的沙盒之前,我们先简单的了解一下 QuickJS 的相关 API,然后再使用 Emscripten 来对其进行编译完成我们的整个示例。 119 | 120 | 121 | 首先我们对 CMake 进行相关修改,增加对 QuickJS 的编译链接支持,如下所示: 122 | 123 | ```cmake 124 | cmake_minimum_required(VERSION 3.15) 125 | project(sample C) 126 | 127 | set(CMAKE_C_STANDARD 11) 128 | include_directories(${PROJECT_SOURCE_DIR}/vendor) 129 | add_subdirectory(${PROJECT_SOURCE_DIR}/vendor/quickjs) 130 | 131 | add_executable(sample main.c) 132 | 133 | target_link_libraries(sample quickjs) 134 | ``` 135 | 136 | 然后我们先通过"Hello World"的输出示例来完成整个项目的初始化,代码如下: 137 | 138 | ```c++ 139 | #include "quickjs/quickjs.h" 140 | 141 | int main() { 142 | JSRuntime *rt = JS_NewRuntime(); 143 | JSContext *ctx = JS_NewContext(rt); 144 | JSValue value = JS_Eval(ctx, "'Hello World!'", 12, "", 0); 145 | printf("%s\n", JS_ToCString(ctx, value)); // Hello World! 146 | JS_FreeContext(ctx); 147 | JS_FreeRuntime(rt); 148 | return 0; 149 | } 150 | ``` 151 | 152 | 现在,对于沙盒内部的 JavaScript 代码而言,我们将会暴露出一个名为 crypto 的全局函数,此函数会对传入的字符串进行 MD5 的相关加密,并且返回给外部环境,其内部 JavaScript 代码如下所示: 153 | 154 | ```javascript 155 | const MD5_SALT_KEY = 'md5 salt key'; 156 | function md5(str) { 157 | // MD5的相关算法实现 158 | // 此处逻辑你可以引入npm上的相关库 159 | // 然后使用Webpack/Parcel等工具进行编译 160 | } 161 | 162 | function crypto(str) { 163 | str = `${str}${MD5_SALT_KEY}`; 164 | return md5(str); 165 | } 166 | ``` 167 | 168 | QuickJS 要调用对应的内部全局 crypto 函数很简单,我们使用 JS_Eval 即可完成: 169 | 170 | ```c++ 171 | #include 172 | #include "quickjs/quickjs.h" 173 | 174 | const char* JS_CODE = "const MD5_SALT_KEY = 'md5 salt key';\n" 175 | "function md5(str) { return str; }\n" 176 | "function crypto(str) {\n" 177 | " return md5(`${str} ${MD5_SALT_KEY}`);\n" 178 | "};"; 179 | 180 | int main() { 181 | JSRuntime *rt = JS_NewRuntime(); 182 | JSContext *ctx = JS_NewContext(rt); 183 | JS_Eval(ctx, JS_CODE, strlen(JS_CODE), "", 0); 184 | JSValue value = JS_Eval(ctx, "crypto('data')", 14, "", 0); 185 | 186 | printf("%s\n", JS_ToCString(ctx, value)); // data md5 salt key 187 | JS_FreeContext(ctx); 188 | JS_FreeRuntime(rt); 189 | return 0; 190 | } 191 | ``` 192 | 193 | 运行我们的代码,我们可以看到,当调用执行结束后其正确的输出了相关内容。接下来我们对目前的实现加入部分 Emscripten 的胶水代码并进行 WebAssembly 的编译,从而使得我们能从 Web 端或 NodeJS 进行相关的执行,调整后的 CMake 如下所示: 194 | 195 | ```cmake 196 | cmake_minimum_required(VERSION 3.15) 197 | project(sample C) 198 | 199 | set(CMAKE_C_STANDARD 11) 200 | set(CMAKE_EXECUTABLE_SUFFIX ".html") # 编译生成.html 201 | 202 | include_directories(${PROJECT_SOURCE_DIR}/vendor) 203 | add_subdirectory(${PROJECT_SOURCE_DIR}/vendor/quickjs) 204 | 205 | add_executable(sample main.c) 206 | 207 | set_target_properties(sample PROPERTIES LINK_FLAGS "\ 208 | -s EXIT_RUNTIME=0 \ 209 | -s EXPORTED_FUNCTIONS=\"['_init', '_eval', '_dispose']\" 210 | ") 211 | 212 | target_link_libraries(sample quickjs) 213 | ``` 214 | 215 | 接着,我们调整我们的 C++部分代码如下: 216 | 217 | ```c++ 218 | #include 219 | #include "quickjs/quickjs.h" 220 | 221 | static JSRuntime *rt; 222 | static JSContext *ctx; 223 | 224 | const char* JS_CODE = "const MD5_SALT_KEY = 'md5 salt key';\n" 225 | "function md5(str) { return str; }\n" 226 | "function crypto(str) {\n" 227 | " return md5(`${str} ${MD5_SALT_KEY}`);\n" 228 | "};"; 229 | 230 | int init(){ 231 | rt = JS_NewRuntime(); 232 | ctx = JS_NewContext(rt); 233 | JS_Eval(ctx, JS_CODE, strlen(JS_CODE), "", 0); 234 | return 0; 235 | } 236 | 237 | int eval(const char *str, int len, char *output){ 238 | JSValue value = JS_Eval(ctx, str, len, "", 0); 239 | const char *retstr = JS_ToCString(ctx, value); 240 | memcpy(output, retstr, strlen(retstr)); 241 | JS_FreeValue(ctx, value); 242 | return 0; 243 | } 244 | 245 | int dispose(){ 246 | JS_FreeContext(ctx); 247 | JS_FreeRuntime(rt); 248 | return 0; 249 | } 250 | ``` 251 | 252 | 最后我们进行相关编译,然后使用如下代码进行相关的调用: 253 | 254 | ```javascript 255 | const datastr = "data"; 256 | const buffer = intArrayFromString(datastr); 257 | const ptr = allocate(buffer, 'i8', ALLOC_NORMAL); 258 | 259 | const output = Module._malloc(1024); 260 | Module._init(); 261 | Module._eval(ptr, buffer.length, output); 262 | Module._dispose(); 263 | console.log(UTF8ToString(output)); // output: data md5 salt key 264 | ``` 265 | 266 | 根据上一章的实践我们知道,由于我们的内部 JavaScript 代码是以字符串的方式直接进行呈现和编译的,因此如果我们对 Emscripten 编译生成 WASM 文件进行二进制查看的话,我们仍然能还原出我们的实际相关实现。同时根据我们的流程来看,我们首先需要将其 JavaScript 内容编译为 Opcode,然后再进行相关的嵌入才较为合理。要想得到 QuickJS 的 Opcode ByteStream 比较简单,直接通过 qjc 即可获得: 267 | 268 | ```shell 269 | > cd quickjs 270 | > make all 271 | > ./qjsc -c index.js 272 | > cat out.c 273 | ``` 274 | 275 | 通过查找 out.c 我们可以看到 qjsc 为我们生成了如下的代码: 276 | 277 | ```c++ 278 | /* File generated automatically by the QuickJS compiler. */ 279 | 280 | #include 281 | 282 | const uint32_t qjsc_index_size = 209; 283 | 284 | const uint8_t qjsc_index[209] = { 285 | 0x02, 0x06, 0x18, 0x4d, 0x44, 0x35, 0x5f, 0x53, 286 | 0x41, 0x4c, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x06, 287 | 0x6d, 0x64, 0x35, 0x0c, 0x63, 0x72, 0x79, 0x70, 288 | // ...... 289 | }; 290 | ``` 291 | 292 | 其中的 qjsc_index 数组就是我们 JavaScript 代码对应的 Opcode ByteStream。接着我们修改我们的代码,然后调整执行的方法,其代码如下: 293 | 294 | ```c++ 295 | const uint8_t JS_CODE[209] = { 296 | 0x02, 0x06, 0x18, 0x4d, 0x44, 0x35, 0x5f, 0x53, 297 | 0x41, 0x4c, 0x54, 0x5f, 0x4b, 0x45, 0x59, 0x06, 298 | 0x6d, 0x64, 0x35, 0x0c, 0x63, 0x72, 0x79, 0x70, 299 | // ...... 300 | }; 301 | 302 | int init(){ 303 | rt = JS_NewRuntime(); 304 | ctx = JS_NewContext(rt); 305 | JS_Eval(ctx, JS_CODE, 209, "", JS_EVAL_TYPE_MODULE); 306 | return 0; 307 | } 308 | ``` 309 | 310 | 最后我们同样进行整体编译,然后尝试相同调用,可以看到其执行正常。同时查看 WASM 文件我们已经无法顺利查看到内部执行 JavaScript 代码内容了(已变为 QuickJS 的 Opcode ByteStream)。 311 | 312 | ### 总结 313 | 314 | 在本章我们较为详细的介绍了前端加密及代码保护的困境,以及如何使用 WebAssembly 并结合 QuickJS 打造高性能安全沙盒的相关介绍,关于 QuickJS 的更进一步的内容可以参考其官方网站。在下一章中,我们将使用 WebAssembly 结合 WebGL 来进行一些图形相关的实践,同时对比其在此领域相比 JavaScript 的优劣势。 -------------------------------------------------------------------------------- /WebAssembly专栏/images/asmjs.benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/asmjs.benchmark.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/asmjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/asmjs.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/benchmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/benchmarks.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/c2j.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/c2j.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/debugging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/debugging.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/emcc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/emcc.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/emscripten.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/emscripten.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/nacl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/nacl.png -------------------------------------------------------------------------------- /WebAssembly专栏/images/opcode.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/opcode.webp -------------------------------------------------------------------------------- /WebAssembly专栏/images/wasm.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/WebAssembly专栏/images/wasm.webp -------------------------------------------------------------------------------- /深入浅出V8优化/1. Smi和HeapNumber.md: -------------------------------------------------------------------------------- 1 | Smi和HeapNumber 2 | --- 3 | 4 | > 本文所述实现仅基于V8 2.0.2.1版本,若后续版本有差异请以后续版本为主 5 | 6 | 在标准中,ECMAScript的Number类型都是Boxed Object(尽管有字面量和Number构造函数之分),如果完全按照规范无脑实现,那么类似于下面的伪代码: 7 | 8 | ```c++ 9 | class Number: public Object { 10 | double value; 11 | } 12 | ``` 13 | 14 | 但这样的实现会存在一些问题: 15 | 1. 所有Number对象均需要分配在堆上,持有一个Number对象需要4字节 + 8字节的大小(32位); 16 | 2. 分配更多的对象,浪费了更多的CPU缓存,对应的存取性能相比纯数值的存取更慢; 17 | 18 | 但现实的开发过程中,我们的程序会被V8基于两个假设进行优化: 19 | 1. 更常使用较小数值的整数,而不是更大的整数值或浮点数; 20 | 2. 我们访问过的数值会在后续频繁地存取,程序的局部性特点很强; 21 | 22 | 因此V8针对Number类型进行了拆分:Smi 和 HeapNumber 23 | 24 | ```javascript 25 | // 在32位系统下,Smi的取值范围为 -2^30 ~ 2**30 - 1 26 | -Infinity // HeapNumber 27 | -(2**30)-1 // HeapNumber 28 | -(2**30) // Smi 29 | -42 // Smi 30 | -0 // HeapNumber 31 | 0 // Smi 32 | 4.2 // HeapNumber 33 | 42 // Smi 34 | 2**30-1 // Smi 35 | 2**30 // HeapNumber 36 | Infinity // HeapNumber 37 | NaN // HeapNumber 38 | ``` 39 | 40 |
41 | 42 |
43 | 44 | ### Smi如何实现的 45 | 46 | Smi的基本想法很简单,即:指针是合法的整形。因此我们可以通过指针转整形来存储一定范围的整数值。 47 | 48 |
49 | 50 |
51 | 52 | 如果我们通过C++简要来实现相关逻辑则代码如下: 53 | 54 | ```c++ 55 | #include 56 | 57 | class Object { 58 | // ...... 59 | } 60 | 61 | class Smi: public Object { 62 | public: 63 | inline int32_t value(){ 64 | return static_cast(reinterpret_cast(this)) >> 1; 65 | } 66 | 67 | static Smi* fromInt(int32_t value){ 68 | intptr_t tagged_value = (static_cast(value) << 1) | 0; 69 | return reinterpret_cast(tagged_value); 70 | } 71 | 72 | static bool isSmi(Smi* ptr){ 73 | return (reinterpret_cast(ptr) & 1) == 0; 74 | } 75 | }; 76 | 77 | int main() { 78 | int32_t value = 23333; 79 | Smi* smi = Smi::fromInt(value); 80 | std::cout << "is smi: " << Smi::isSmi(smi) << std::endl; 81 | std::cout << "is equal: " << (value == smi->value()) << std::endl; 82 | return 0; 83 | } 84 | ``` 85 | 86 | ### HeapNumber如何实现的 87 | 88 | 在V8中,HeapNumber的继承关系如图所示: 89 | 90 | ```c++ 91 | class Object { 92 | public: 93 | static const int32_t kHeaderSize = 0; 94 | } 95 | 96 | class HeapObject: public Object { 97 | public: 98 | static const int32_t kHeaderSize = Object::kHeaderSize + 4; // 必须的指针长度 99 | } 100 | 101 | class HeapNumber: public HeapObject { 102 | public: 103 | static const int32_t kSize = HeapObject::kHeaderSize + 8; // 指针(4) + double(8) 104 | } 105 | ``` 106 | 107 | 其一个HeapNumber对象自身占用12字节内存(在32位系统中),由于GC的原因(Alloc的返回对象均为Object指针),HeapNumber的内存布局直接采用偏移量处理的方式(这里又涉及一个Trick): 108 | 109 |
110 | 111 |
112 | 113 | 因此HeapNumber的存取需要自行手工的对内存偏移进行处理,简要实现的代码如下: 114 | 115 | ```c++ 116 | #include 117 | 118 | const int32_t kHeapObjectTag = 1; 119 | 120 | class Object { 121 | public: 122 | static const int32_t kHeaderSize = 0; 123 | 124 | bool isHeapObject() { 125 | return (reinterpret_cast(this) & 3) == kHeapObjectTag; 126 | } 127 | }; 128 | 129 | class HeapObject:public Object { 130 | public: 131 | static const int32_t kHeaderSize = Object::kHeaderSize + 4; 132 | 133 | void set_map(void* map) { 134 | uint8_t* ptr = reinterpret_cast(this) - kHeapObjectTag; 135 | *reinterpret_cast(ptr) = reinterpret_cast(map); 136 | } 137 | 138 | static HeapObject* cast(Object* ptr){ 139 | return reinterpret_cast(ptr); 140 | } 141 | }; 142 | 143 | class HeapNumber:public HeapObject { 144 | public: 145 | static const int32_t kSize = HeapObject::kHeaderSize + 8; 146 | static const int32_t kValueOffset = HeapObject::kHeaderSize; 147 | 148 | void set_value(double value) { 149 | uint8_t *ptr = reinterpret_cast(this) + kValueOffset - kHeapObjectTag; 150 | *reinterpret_cast(ptr) = value; 151 | } 152 | 153 | double value() { 154 | uint8_t *ptr = reinterpret_cast(this) + kValueOffset - kHeapObjectTag; 155 | return *reinterpret_cast(ptr); 156 | } 157 | 158 | static HeapNumber* cast(Object* ptr) { 159 | return reinterpret_cast(ptr); 160 | } 161 | }; 162 | 163 | Object* AllocateRaw(size_t size) { 164 | return reinterpret_cast(malloc(size)) + kHeapObjectTag; 165 | } 166 | 167 | int main() { 168 | Object* result = AllocateRaw(HeapNumber::kSize); 169 | // 实际上这里源码是塞入了 heap_number_map() 170 | // map结构比较复杂,我们暂时略去以后讲,这里并不重要 171 | HeapObject::cast(result)->set_map(malloc(4)); 172 | HeapNumber::cast(result)->set_value(2.3333); 173 | std::cout << "is HeapObject: " << result->isHeapObject() << std::endl; 174 | std::cout << "value: " << HeapNumber::cast(result)->value() << std::endl; 175 | return 0; 176 | } 177 | ``` 178 | 179 | 在这里我们注意到不管是在AllocateRaw,还是HeapNumber的set_value及value方法调用过程中,除了自身偏移的4字节外还额外的偏移了1个字节。并且我们也不难发现,对应的1字节在isHeapObject中起到了对应的作用,这是为什么呢?原因是:V8通过内存地址4字节对齐的特点(32位系统),减少了1字节的成员属性存储的开销。 180 | 181 | 那什么叫内存地址4字节对齐呢?简单来说就是每个内存地址是占用4个字节的,如果我们从0开始,那么下一个内存的地址就是4、8、12、16...从这里我们看到,对应内存地址至少低2位是无法被利用的,比如我们将4字节写成二进制数的话就是: 182 | 183 | 184 | ```shell 185 | ...00000000 00000000 00000000 00000100 186 | ``` 187 | 188 | 从上面你可以看到第三位为1,但是低两位一定会是0,因此如果我们对低两位进行操作的话,并不会造成非法操作,在这里V8就是利用了这个特性用低位01来判断是否是一个HeapObject,因此我们可以像Smi一样得到更完整的HeapObject内存布局: 189 | 190 |
191 | 192 |
193 | 194 | 那为什么要大费周折的这么做呢?毕竟2022年手机内存都标配8G了,我们也不差1字节内存呀!其实是因为由于JavaScript是弱类型语言,其很多内部函数都需要判断对象的类型然后再进行对应操作,因此判断类型这个操作真的太太太频繁了。不管是从内存还是CPU缓存中去读取都比位操作而言耗费更多时间,秉持能省则省的原则,我们就势必需要这么极致的优化了。 195 | 196 | ### NaN和Infinity 197 | 198 | 如果有细心的同学话,一定会注意到在Smi的分类中,NaN和Infinity被归属到了HeapNumber,那为什么不是用一个枚举类型或者标志位来表示呢?为什么要让NaN和Infinity搞成一个HeapNumber,带一个这么大的double类型来浪费呢?如果想知道这个问题,我们需要深入到IEEE754标准里面去。 199 | 200 | 我们知道对于JavaScript的Number类型而言,其实质就是一个64位的浮点数类型。对于64位浮点数的标准我们通常遵循IEEE754,其对应表示如下: 201 | 202 |
203 | 204 |
205 | 206 | 其计算公式如下: 207 | 208 | ![](https://latex.codecogs.com/svg.latex?(-1)^a*2^{(b+1023)}*(1+\frac{c}{2^{51}})) 209 | 210 | 但实际上IEEE754有一套自己的规则,其总结起来就是: 211 | 212 |
213 | 214 |
215 | 216 | 大家可以看到,实际上对于double而言,我们完全利用IEE754自身的规则来进行NaN和Infinity的表达,因此自然而言NaN和Infinity就归属到了HeapNumber中了。 217 | 218 | ### 题外话:Boolean、undefined以及null 219 | 220 | 我们知道,对于布尔值而言其True及False完全可以由数值1和0来代表,那么在V8里关于Boolean的实现逻辑是使用Smi还是HeapNumber来表示呢?实际上在V8的实现中,Boolean既不是Smi也不是HeapNumber,而是一个Heap::true_value和Heap::false_value。于此同时对应的undefined及null也采用了相同的实现,指向了Heap::undefined_value和Heap::null_value。这里的Heap::*_value实际上等价于某个Map对象(HeapNumber也含有一个Map指针),因此你程序里的Boolean值都通通指向了一个相同的Map对象,如图所示: 221 | 222 |
223 | 224 |
225 | Map是非常重要的结构,他决定了对象的类型、大小、原型甚至面试八股文里面常聊的HiddenClass,我们会在后续多篇中来进行剖析。 226 | -------------------------------------------------------------------------------- /深入浅出V8优化/2. 字符串的优化.md: -------------------------------------------------------------------------------- 1 | ## 字符串的优化 2 | 3 | > 本文所述实现仅基于 V8 2.0.2.1 版本,若后续版本有差异请以后续版本为主 4 | 5 | 在讲述完 Number、NaN、null 及 undefined 之后,我们一起来探究一下 V8 里基础数据中字符串的相关优化。在 JavaScript 的程序里,字符串是无所不在的,它贯穿了我们数据和代码的每一个角落,由于它确实太常用了,我们不得不对其做一些相关优化以保证我们整个程序的性能。 6 | 7 | 首先让我们来看一看 ECMAScript 规范对字符串的定义,其表述如下: 8 | 9 | > The String type is the set of all finite ordered sequences of zero or more 16-bit unsigned integer values 10 | > (“elements”). 11 | 12 | 这段描述里有两个地方需要我们特别注意: 13 | 14 | 1. finite ordered sequences 15 | 2. 16-bit unsigned integer values 16 | 17 | finite ordered sequences 说明我们的字符串是自适应长度且有序的序列,16-bit unsigned integer 代表着每个字符单元占用了双字节(即 UTF-16)。因此如果我们无脑按照规范来实现我们的字符串类的话则代码如下: 18 | 19 | ```c++ 20 | class String { 21 | uint32_t length; 22 | uint16_t* data; 23 | } 24 | ``` 25 | 26 | 规范的这种定义带来了非常多的好处,比如:由于我们的每个字符单元占用双字节,因此最大值可以存储到 65536,在这个范围内大部分的文字(比如中文)都可以直接被存储,非常方便且高效。但事物总归会有两面性,这种定义也会带来非常多的问题: 27 | 28 | 1. 双字节的设计可以很容易容纳大部分字体,但是对于仅使用 ASCII 字符的地区,这无疑会造成非常大的内存浪费(ASCII 单元只需要一个字节即可); 29 | 2. 我们现在字符编码主流为 UTF-8,UTF-16 的设计对于使用者而言不太友好,需要使用者自行考虑对应的编码转换; 30 | 3. ...... 31 | 32 | 对应这些诸多的情况,V8 内部会在符合规范的基础上对 ASCII 和 UTF-8 做特殊处理,自动判断对应字符串的类型,然后采取不同的优化策略进行处理。除此之外,假如我们不考虑上面的问题真的如上所属的代码进行无脑实现,那么其在字符串的连接等操作上也会非常的浪费性能,因为这种做法会频繁涉及到内存的申请、复制、销毁。我们知道,当你的程序涉及到这些内存操作时是会非常影响性能的,所以 V8 也针对性的对于这种情况进行了特殊处理。 33 | 34 | 在 V8 2.0.2.1 版本中,字符串类的继承关系可以概括如下: 35 | 36 | ```c++ 37 | // - String 38 | // - SeqString 39 | // - SeqAsciiString 40 | // - SeqTwoByteString 41 | // - ConsString 42 | // - ExternalString 43 | // - ExternalAsciiString 44 | // - ExternalTwoByteString 45 | ``` 46 | 47 | 接下来我们就一起来逐步分析其每个类型所采用的优化思路和考量吧。 48 | 49 | > 实际上 V8 对应字符串的优化思路非常直接且好懂,其用到的数据结构和算法非常简单,相比于面试常考的 “接雨水” 此类算法题而言其理解难度低很多很多,因此你无须害怕和紧张,让我们先深呼吸一口气继续探究吧。 50 | 51 | ### String 的实现 52 | 53 | String 类是所有字符串类的父类,在 V8 的实现中,实际上所有字符串子类在对外使用时都会被“里氏替换”为父类 String 来使用。在我们第一章的学习中我们知道,在 V8 的实现中存在 HeapObject 这个基类,其相关占用的内存主要由 GC 管理。在这里,由于 String 类需要涉及到堆内存的申请和释放,因此其必然是 HeapObject 的子类。那么对于 HeapObject 的子类而言,我们必然需要对其考虑内存布局,代码简要实现如下: 54 | 55 | ```c++ 56 | class Object { 57 | public: 58 | static const int32_t kHeaderSize = 0; 59 | }; 60 | 61 | class HeapObject: public Object { 62 | public: 63 | static const int32_t kHeaderSize = Object::kHeaderSize + 4; 64 | }; 65 | 66 | class String:public HeapObject { 67 | public: 68 | static const int kLengthOffset = HeapObject::kHeaderSize; 69 | static const int kSize = kLengthOffset + 4; 70 | }; 71 | ``` 72 | 通过我们上一章的学习,我们能够很轻松的画出 String 的内存布局,图如下: 73 | 74 |
75 | 76 |
77 | 78 | 那字符串的相关数据并不在我们的内存布局当中,他们究竟放在哪里呢?这个要分情况讨论, SeqString 和 ConsString 的情况并不一致,在 V8 中会进行独立的处理和优化,这个我们在后面的讨论中可以看到。但无论如何,从上面的内存不居中我们可以知道,String 里肯定会包含 length 和 set_length 两个基本方法,在这里我们简要的实现一下相关方法,代码如下: 79 | 80 | ```c++ 81 | class String:public HeapObject { 82 | inline int length() { 83 | uint32_t len = READ_INT_FIELD(this, kLengthOffset); 84 | return len >> (StringShape(this).size_tag() + kLongLengthShift); 85 | } 86 | 87 | inline void set_length(int value) { 88 | WRITE_INT_FIELD( 89 | this, 90 | kLengthOffset, 91 | value << (StringShape(this).size_tag() + kLongLengthShift 92 | ); 93 | } 94 | } 95 | ``` 96 | 97 | 整体逻辑都比较易懂,除了意外出现的 StringShape 类,这个我们在本章的题外话中再简要聊聊。需要注意的是,在V8 2.0.2.1版本中,32位系统字符串最长长度为2^30-1,剩余的2位用于了字符串各种子类型的判断,这个技巧我们在前一章有描述过,在这里我们就不继续聊了(尽管标准说字符串长度是无限制的,但2^30-1长度的字符串大小已经超过了32位V8运行时的最大内存,所以严格来说这里并不符合标准,但现实中我们也大概率用不上不会用到这么大的字符串,因此我们勉强可以说这符合标准)。 98 | ### SeqString 的实现 99 | 100 | SeqString 的实现总体很简单,其在 String 内存布局的基础之上增加了一块连续内存用来存放对应的字符串相关数据,其内存布局如下图所示: 101 | 102 |
103 | 104 |
105 | 106 | 从规范我们知道,JavaScript 内部存放的是 UTF-16 单元,因此 Data 顺理成章的就是一块uint16的堆内存序列了,那么整体的想法就和我们想的设计不谋而合,简化代码如下: 107 | 108 | ```c++ 109 | class SeqString { 110 | uint32_t length; 111 | uint16_t* data; 112 | } 113 | ``` 114 | 115 | 但我们也注意到,SeqString 有两个子类分别叫做 SeqAsciiString 和 SeqTwoByteString,从名字上我们能够知道,一个是专门为 ASCII 做优化的设计,其存放的是 uint8 内存序列,而另一个 SeqTwoByteString 就是直接的 uint16 内存序列的实现。这么做的道理很简单,因为对于 ASCII 而言,其只需要 uint8 单元即可进行存放,如果我们使用 uin16 单元来进行存放 ASCII 的话,这实际上有点太过于浪费了,况且在目前的使用中大量的相关信息都是以英文为主,所以针对 ASCII 进行优化是有必要的。那你可能会问,现在不都是使用 UTF-8 编码了么?其实我们稍微了解相关知识就可以知道 UTF-8 编码是完全兼容于 ASCII 的,因此对于这块而言并不会带来什么问题,这块从创建一个 SeqString 的相关代码中我们也能看到: 116 | 117 | ```c++ 118 | Object* Heap::AllocateStringFromUtf8( 119 | Vector string, 120 | PretenureFlag pretenure 121 | ) { 122 | // Count the number of characters in the UTF-8 string and check if 123 | // it is an ASCII string. 124 | Access decoder(Scanner::utf8_decoder()); 125 | decoder->Reset(string.start(), string.length()); 126 | int chars = 0; 127 | bool is_ascii = true; 128 | while (decoder->has_more()) { 129 | uc32 r = decoder->GetNext(); 130 | if (r > String::kMaxAsciiCharCode) is_ascii = false; 131 | chars++; 132 | } 133 | 134 | // If the string is ascii, we do not need to convert the characters 135 | // since UTF8 is backwards compatible with ascii. 136 | if (is_ascii) return AllocateStringFromAscii(string, pretenure); 137 | 138 | Object* result = AllocateRawTwoByteString(chars, pretenure); 139 | if (result->IsFailure()) return result; 140 | 141 | // Convert and copy the characters into the new object. 142 | String* string_result = String::cast(result); 143 | decoder->Reset(string.start(), string.length()); 144 | for (int i = 0; i < chars; i++) { 145 | uc32 r = decoder->GetNext(); 146 | string_result->Set(i, r); 147 | } 148 | return result; 149 | } 150 | ``` 151 | 152 | 上面的逻辑简单来说就是,创建一个字符串对先前会对字符串的所有字符进行遍历,判断是完全的 ASCII 还是 UTF-8 编码,如果是完全的 ASCII字符串那么直接调用 AllocateStringFromAscii 方法进行 SeqAsciiString 的创建,如果是 UTF-8 编码,那么就创建对应的 SeqTwoByteString 实例,并解析 UTF-8 单元将其一个一个填充到对应的 UTF-16 单元中。 153 | 154 | 当然到这里还没有完全结束,在不管是调用 AllocateStringFromAscii 还是 AllocateRawTwoByteString 的代码中我们都可以看到 V8 还根据对应字符串的长度进行了分类: 155 | - Short String(长度小于等于63) 156 | - Medium String(长度小于等于16383且大于63) 157 | - Long String(长度大于16383且小于2^30-1) 158 | 159 | 其代码片段如下: 160 | ```c++ 161 | Object* Heap::AllocateRawTwoByteString(int length, PretenureFlag pretenure) { 162 | // .... 163 | 164 | // 根据字符串的对应长度决定对于的map指向 165 | Map* map; 166 | if (length <= String::kMaxShortSize) { 167 | map = short_string_map(); 168 | } else if (length <= String::kMaxMediumSize) { 169 | map = medium_string_map(); 170 | } else { 171 | map = long_string_map(); 172 | } 173 | 174 | // .... 175 | } 176 | ``` 177 | 178 | 按照字符串长度进行分类的目的有两个: 179 | - 为了更好的方便 GC 进行标记, 180 | - 对于某些方法(比如 Hash)能够更好的进行独立优化 181 | 182 | 我们在这里只简要介绍为何能够方便 GC 标记,对于方法的独立优化的解释请查看下方的题外话二:String Hash了解。如果有了解过 V8 GC 的朋友应该知道 V8 的 GC 是混合式的精准GC ,其包含了分代GC、GC复制、GC标记-清除、GC标记-压缩等多个相关算法。对于传统的标记清除算法而言,有两个最大的问题是: 183 | - 内存的碎片化 184 | - 跳跃式访问标记 185 | 186 | 简单理解可以由如下图所示: 187 | 188 |
189 | 190 |
191 | 192 | 为了解决这个问题,V8 的 GC 的处理方式是将所有需要 GC 管理的类及其子类都进行详细的分类,将对应同样类型的实例都存放在一起,这样的话就能够进行连续的标记,而避免跨越式的访问和内存碎片化,如图所示: 193 | 194 |
195 | 196 |
197 | 198 | 199 | ### ConsString 的实现 200 | 201 | ECMAScript 里规定了对应的字符串是不可变的,所以当使用了对应的字符串操作时,其返回的总是新的字符串: 202 | 203 | ```javascript 204 | var a = 'a'; 205 | var b = 'b'; 206 | console.log(a + b); // new string instance: 'ab' 207 | ``` 208 | 在实际的程序里面,字符串的连接操作是一个非常常用的操作,按照常规的实现思路,如果我们要将两个字符串连接生成一个新的字符串,那么最简单的方式就是申请一块新的内存(长度为两个操作字符串长度之和),然后将操作字符串逐字符进行拷贝。这样的做法对于长度较小的字符串是没有太多问题的,但是如果字符串的长度达到了 Medium 或者 Long,这样的操作的耗时就难免较长,因此在V8里面针对这种情况进行了优化,编写了 ConsString 用来解决此类问题。 209 | 210 | ConsString 的优化思路很简单,其采用二叉树的方式来生成一个全新的字符串实例,其内存布局如图所示: 211 | 212 |
213 | 214 |
215 | 216 | 通过这种方式,其避免了 Medium / Long字符串的内存申请及其拷贝的消耗,有效的增强了字符串连接的性能,对应的代码如下: 217 | 218 | ```c++ 219 | Object* Heap::AllocateConsString(String* first, String* second) { 220 | int first_length = first->length(); 221 | if (first_length == 0) { 222 | return second; 223 | } 224 | 225 | int second_length = second->length(); 226 | if (second_length == 0) { 227 | return first; 228 | } 229 | 230 | int length = first_length + second_length; 231 | 232 | // 如果对应长度小于kMinNonFlatLength(13),则直接走SeqString的逻辑 233 | if (length < String::kMinNonFlatLength) { 234 | // ...... 235 | } 236 | 237 | // 否则走二叉树的ConsString逻辑 238 | Object* result = Allocate( 239 | map, 240 | always_allocate() ? OLD_POINTER_SPACE : NEW_SPACE 241 | ); 242 | 243 | if (result->IsFailure()) return result; 244 | ConsString* cons_string = ConsString::cast(result); 245 | WriteBarrierMode mode = cons_string->GetWriteBarrierMode(); 246 | cons_string->set_first(first, mode); 247 | cons_string->set_second(second, mode); 248 | cons_string->set_length(length); 249 | return result; 250 | } 251 | ``` 252 | 253 | 当然,凡事有利有弊,ConsString 虽然增强了字符串的连接性能,但是在某些字符串方法里却增加了复杂性,比如我们常用的 String.prototype.slice 方法,如果对于切分的字符串一部分在 first 里一部分在 second 里的话,我们的实现就会非常困难,并且对应性能也不会特别好,那因此V8遇到此类情况, 会将 ConsString 进行 Flatten,通过空间换取此类操作的执行性能,代码如下: 254 | 255 | ```c++ 256 | // 仅针对于V8 2.0.2.1版本 257 | // 后续版本创造了SliceString来解决相关性能问题 258 | Object* Heap::AllocateSubString(String* buffer, 259 | int start, 260 | int end) { 261 | int length = end - start; 262 | 263 | // .... 264 | 265 | // 如果对应的字符串是ConsString, 则进行Flatten减少访问时间 266 | if (!buffer->IsFlat()) { 267 | buffer->TryFlatten(); 268 | } 269 | 270 | // .... 271 | 272 | // 生成全新的字符串进行返回 273 | if (buffer->IsAsciiRepresentation()) { 274 | ASSERT(string_result->IsAsciiRepresentation()); 275 | char* dest = SeqAsciiString::cast(string_result)->GetChars(); 276 | String::WriteToFlat(buffer, dest, start, end); 277 | } else { 278 | ASSERT(string_result->IsTwoByteRepresentation()); 279 | uc16* dest = SeqTwoByteString::cast(string_result)->GetChars(); 280 | String::WriteToFlat(buffer, dest, start, end); 281 | } 282 | 283 | return result; 284 | } 285 | ``` 286 | 287 | 那什么样的字符串为 Flatten 字符串呢,其分为两种: 288 | - 所有 SeqString 相关子类 289 | - 类型为 ConsString,但仅有 first 指针有数据,second 指针指向 nullptr 290 | 291 | ### ExternalString 的实现 292 | 293 | ExternalString 在 V8 2.0.2.1中实际上与 SeqString 并没有太多差异,相关内容请参考 SeqString 的描述。 294 | 295 | ### 题外话一:StringShape 296 | 297 | 我们知道 String 是 HeapObject 的子类,从上面的内存布局中我们还可以知道其会有4字节的 Map 指针引用。如果我们要判断一个 String 的相关类型的话,我们的对应代码基本上会这样写: 298 | ```c++ 299 | uint32_t type = HeapObject::cast(this)->map()->instance_type(); 300 | if((type & (kIsNotStringMask | kStringRepresentationMask)) == (kStringTag | kConsStringTag)) { 301 | // something to do... 302 | } 303 | ``` 304 | 我们之前有说过,类型判断操作在 JavaScript 里非常频繁。对于 String 而言其要获取 type 实际上会经过两次内存相关的访问:map() 及 instance_type(),这个消耗看起来非常的不划算,在 V8 的 StringShape 注释里也有相关的注释: 305 | 306 | ```shell 307 | // The characteristics of a string are stored in its map. Retrieving these 308 | // few bits of information is moderately expensive, involving two memory 309 | // loads where the second is dependent on the first. To improve efficiency 310 | // the shape of the string is given its own class so that it can be retrieved 311 | // once and used for several string operations. 312 | ``` 313 | 但在整个 V8 的代码中,我们却基本上看不到缓存 StringShape 实例的代码,而全部都只是将 StringShape 当成一个临时实例在使用,比如: 314 | 315 | ```c++ 316 | if(StringShape(this).IsSymbol()) { 317 | // ... 318 | }else if(StringShape(this).IsCons()) { 319 | // ... 320 | } 321 | ``` 322 | 323 | 这不免让我们思考:那既然类型的访问这么昂贵,我们为什么不将这个代码修改为: 324 | ```c++ 325 | StringShape shape = StringShape(this) 326 | if(shape.IsSymbol()) { 327 | // ... 328 | }else if(shape.IsCons()) { 329 | // ... 330 | } 331 | ``` 332 | 甚至我们还可以在 StringShape 中做各种字段的缓存以便重复使用。其原因在于 ConsString 和 GC,正如对应注释里说到: 333 | 334 | ```shell 335 | // but be aware that flattening a string can potentially alter its shape. 336 | // Also be aware that a GC caused by something else can alter the shape of a 337 | // string due to ConsString shortcutting. Keeping these restrictions in mind has 338 | // proven to be error-prone and so we no longer put StringShapes in variables 339 | // unless there is a concrete performance benefit at that particular point in the code. 340 | ``` 341 | 尽管 StringShape 在性能提升上没有发挥用处,但由于其增强了代码的可读性(比较一下最开头的那个 String 类型判断代码),因此V8至此最新版本仍然保留了 StringShape 的相关实现。 342 | 343 | ### 题外话二:String Hash 344 | 345 | 在 JavaScript 里面字符串不仅仅可以作为普通变量使用,实际上对象的 Property 也需要使用到字符串,例如: 346 | ```javascript 347 | var obj = {}; 348 | obj['a'] = 'a'; 349 | obj['b'] = 'b' 350 | ``` 351 | 那这里就出现了一个问题,难不成我们的对象实现里面就真的持有了完整的字符串内容?当我们获取对应值的时候岂不是会涉及到字符串逐字符比较,那不是慢到爆炸?这个问题实现者当然也想到了,解决办法的是将其转换成 Int 类型的 Hash,在后续的比较中通过 Hash 值来进行比较。所以在 String 中编写了 Hash、ComputeAndSetHash 等方法,对应的定义如下: 352 | 353 | ```c++ 354 | class String: public HeapObject { 355 | inline uint32_t Hash(); 356 | 357 | static uint32_t ComputeLengthAndHashField( 358 | unibrow::CharacterStream* buffer, 359 | int length 360 | ); 361 | } 362 | ``` 363 | 364 | 上述代码中的 unibrow 实际上是 V8 早期自己实现的 Unicode 库,在后续的V8版本中已被替换为 ICU,其具体实现对于我们而言并不重要,我们只需要知道其保存了 Unicode 的字符流即可。首先让我们看看 Hash 方法: 365 | 366 | ```c++ 367 | inline uint32_t String::Hash() { 368 | // Fast case: has hash code already been computed? 369 | uint32_t field = length_field(); 370 | if (field & kHashComputedMask) return field >> kHashShift; 371 | // Slow case: compute hash code and set it. 372 | return ComputeAndSetHash(); 373 | } 374 | ``` 375 | 376 | 整体代码很清晰,分为了 Fast Path 和 Slow Path 两个部分。但奇怪的是为什么 Fast Path 部分是从 Length 字段里获取相关值然后操作后返回呢?实际上从文章上面的章节内容我们可以知道, String 是被分为 Short / Medium / Long 三种类型,Short / Medium 对应的长度较短(最大值为63和16383),但是 Length 字段是 uint32 类型的,因此对于 Short / Medium 的 String 而言还有部分空间是完全可以利用起来存放 Hash 值的,简单来说字符串计算得出的 Hash 值是和 Length 字段共存的!当然,对于 Long 类型的字符串而言需要有额外的处理,这个我们在下面会介绍,接下来让我们看看 ComputeAndSetHash 的逻辑: 377 | ```c++ 378 | uint32_t String::ComputeAndSetHash() { 379 | // Compute the hash code. 380 | StringInputBuffer buffer(this); 381 | uint32_t field = ComputeLengthAndHashField(&buffer, length()); 382 | 383 | // Store the hash code in the object's length field. 384 | set_length_field(field); 385 | return field >> kHashShift; 386 | } 387 | ``` 388 | 389 | 从代码中的注释我们可以确认:Hash 值确实是和 Length 字段是放一起的。而如果要了解对应的计算逻辑的话,就让我们看一看 ComputedLengthAndHashField 方法,其代码如下: 390 | ```c++ 391 | uint32_t String::ComputeLengthAndHashField( 392 | unibrow::CharacterStream* buffer, 393 | int length 394 | ) { 395 | StringHasher hasher(length); 396 | 397 | // Very long strings have a trivial hash that doesn't inspect the 398 | // string contents. 399 | if (hasher.has_trivial_hash()) { 400 | return hasher.GetHashField(); 401 | } 402 | 403 | // Do the iterative array index computation as long as there is a 404 | // chance this is an array index. 405 | while (buffer->has_more() && hasher.is_array_index()) { 406 | hasher.AddCharacter(buffer->GetNext()); 407 | } 408 | 409 | // Process the remaining characters without updating the array 410 | // index. 411 | while (buffer->has_more()) { 412 | hasher.AddCharacterNoIndex(buffer->GetNext()); 413 | } 414 | 415 | return hasher.GetHashField(); 416 | } 417 | ``` 418 | 这里新出现了一个 StringHasher 类,这个类对于 String 的内容使用了名为 [Jenkins One-At-A-Time Hash Funcation](https://en.wikipedia.org/wiki/Jenkins_hash_function) 的算法进行了快速的 Hash 计算。在这里我们不免会有疑问,Hash 计算对于 Short / Medium 的 String 尚可还行得通,但是我们有说到 String 的最大长度可以达到2^30-1(针对于32位),如果我的一个字符串达到了这个长度的话,假设忽略 Hash 的计算性能,对应的 Hash 值也应该达到 Length 的存储最大值了,我们又说过 Hash 是和 Length 字段共存的,那这样岂不是会将 Length 字段进行覆写,我们之后获取 String 的长度该怎么办呢?V8 的解决办法是:对于 Long String 我们直接将 Length 作为整个 String 的 Hash 进行使用,代码如下: 419 | ```c++ 420 | static inline uint32_t HashField(uint32_t hash, bool is_array_index) { 421 | uint32_t result = (hash << String::kLongLengthShift) | String::kHashComputedMask; 422 | if (is_array_index) result |= String::kIsArrayIndexMask; 423 | return result; 424 | } 425 | 426 | uint32_t StringHasher::GetHashField() { 427 | if (length_ <= String::kMaxShortSize) { 428 | // ... 429 | } else if (length_ <= String::kMaxMediumSize) { 430 | // ... 431 | } else { 432 | // 对于Long String,则直接使用Length字段作为Hash来使用 433 | // 对应了ComputeLengthAndHashField中的注释: 434 | // 'Very long strings have a trivial hash that doesn't inspect the 435 | // string contents.' 436 | return v8::internal::HashField(length_, false); 437 | } 438 | } 439 | ``` 440 | 但如果我们往深想一下,这样的处理难道不会 Hash 碰撞情况十分多么?比如对于下面的代码: 441 | ```javascript 442 | // 测试环境 V8 2.0.2.1 32位 443 | var key1 = new Array(16384).fill(0).join(''); 444 | var key2 = new Array(16384).fill(1).join(''); 445 | var obj = {}; 446 | obj[key1] = 0; // force to compute hash 447 | obj[key2] = 1; // force to compute hash 448 | console.log(key1 == key2) // output: false 449 | ``` 450 | 451 | 对于这段简单的代码,我们很自然就会知道它的输出就应该是false才对,但如果按照上面的逻辑,对于大于长度16383的 Long String,如果我们对其做比较,由于 Length 就是对应字符串的 Hash 值,输出结果不就应该是true才对么?实际上V8对于字符串是否相等的比较仍然是逐字符进行比对, Hash 的比较只是作为了一个 Fast Path 来进行了处理,代码如下: 452 | ```c++ 453 | bool String::SlowEquals(String* other) { 454 | // Fast check: negative check with lengths. 455 | int len = length(); 456 | if (len != other->length()) return false; 457 | if (len == 0) return true; 458 | 459 | // Fast check: if hash code is computed for both strings 460 | // a fast negative check can be performed. 461 | if (HasHashCode() && other->HasHashCode()) { 462 | if (Hash() != other->Hash()) return false; 463 | } 464 | 465 | // 下面的代码就是针对于不同的String类型进行逐字符的对比处理 466 | // 相关代码内容太多,我们在这里直接忽略 467 | } 468 | ``` 469 | 在 SlowEquals 方法中,当两者的 Hash 均不相等时其才命中 Fast Path 直接返回 false,这样的话对于相等 Hash 的 Long String,其最终也会落入到逐字符比较的相关逻辑中,而并不会出现上面我们讨论的问题。其他字符串相关的比较方法大致相同,但是也会有一些细微差别,我们在之后的章节里会再回过头来详细讨论。 470 | -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.bool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.bool.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.heapnumber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.heapnumber.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.heapobject.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.heapobject.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.iee754.impl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.iee754.impl.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.iee754.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.iee754.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.jsobject.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.jsobject.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/smi.smi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/smi.smi.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/string.cons.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/string.cons.jpg -------------------------------------------------------------------------------- /深入浅出V8优化/images/string.gc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/string.gc.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/string.gc2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/string.gc2.jpg -------------------------------------------------------------------------------- /深入浅出V8优化/images/string.layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/string.layout.png -------------------------------------------------------------------------------- /深入浅出V8优化/images/string.seq.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/深入浅出V8优化/images/string.seq.jpg -------------------------------------------------------------------------------- /深入浅出动态化SSR服务/深入浅出动态化SSR服务(一).md: -------------------------------------------------------------------------------- 1 | 深入浅出动态化SSR服务(之一) - 开发工具篇 2 | ---- 3 | 4 | ### 目录 5 | * 开发工具篇 🔥 6 | * SSR服务篇 7 | * 架构篇 8 | 9 | ### 简述 10 | 在前端还未系统化之时的刀耕火种时代,已经有非常多的生成页面的工具,其可视化的方式极大的赋能了非技术人员,加快了业务的迭代速度。如今,随着前端技术的发展和复杂化,我们看到越来越多以组件为基础的页面级可视化生成工具已经出现在了各大领域。 11 | 12 | 依靠`Vue`/`React`等UI组件框架和逐渐易用化的`Webpack`等编译工具的出现,编写一套符合自己业务需求的前端可视化页面生成工具并不复杂。但大部分的页面可视化系统大都以完全纯前端思维来打造,对其论述也仅仅是大体的做法和原理剖析,并没有以前端工程化的角度来阐述其中的困难与细节。 13 | 14 | 在 《深入浅出动态化SSR服务》系列文章中,我们将以较为深入的前端工程化角度来讲述如何打造一款动态化且支持SSR的页面可视化系统。希望大家能以此为参考,更深入的思考前端工程化的实践,并能在业务当中有相关的提升。 15 | 16 | 本系列文章分为三个部分: 17 | 1. 开发工具篇: 当前篇我们会较为深入和系统地讲述`sis`的编写过程,其中涉及到技术选型考量,编译前端相关的知识。同时我们会适当讲述不直接采用`Vue CLI`等工具链的原因及其相关的不足点,更多的内容在《SSR服务篇》中我们还会通过测试结果进行更深入分析和讲述。 18 | 2. SSR服务篇:在这一篇中我们会探讨当前SSR服务性能和稳定性相关的问题,在压力测试工具以及`Node`相关Profile帮助下优化我们的SSR服务,达到单机服务高稳定性、高性能、动态化的要求。 19 | 3. 架构篇:在这一篇中我们会讲述此页面可视化系统的架构层面的一些思考和实践,从更全局和整体的角度来探讨如何保证系统的高稳定和高性能化要求。 20 | 21 | 在此,我非常感谢百度的FEX团队产出的 [FIS工具](http://fis.baidu.com/),其中非常多关于前端工程化的思考都被融入到了`sis`以及`sis-ssr`中。同时我也非常感谢与我亦师亦友的 [FIS作者/前端工程化先行人 - 张云龙](https://github.com/fouber) 从全民直播至今对我整体技术方向的指引和多维度方法论的培养。 22 | 23 | ### 项目背景 24 | 如今市面上的页面可视化系统主要以纯前端技术为主,并没有对结果页面进行SSR同构。究其原因可以总结为:纯前端技术的整体系统实现较为容易,对于其产出结果只需要由CDN来进行加速,即可完成抵抗单页面大流量的需求。我们可以看到目前市面上类似的运营/营销产品可视化系统大体都是如此。 25 | 26 | 但是SSR同构服务的引入是有其积极意义的,其核心的两点在于: 27 | 1. SEO友好 28 | 2. 首屏渲染的加速 29 | 30 | 对于很多站点而言,其在SEO上的需求与大型厂商的系统有很大的不同:有非常多站点的产品页面仍然需要Google等搜索引擎进行收录。这点我们可以从开放的 [robots.txt](https://www.coupang.com/robots.txt) 文件看到: 31 | 32 | ```html 33 | User-agent: Googlebot 34 | Crawl-delay: 1 35 | Allow: /vp/products/ 36 | Allow: /vm/products/ 37 | Disallow: /*.css$ 38 | Disallow: /*.js$ 39 | ``` 40 | 41 | 在这里可能有部分读者会问:“Google不是可以对SPA页面进行收录么?”,其实不然,我们在Google的相关解释中可以找到这样一句话: 42 | > Note: that as of now, only can index synchronous JavaScript applications just fine. 43 | 44 | 因此,当我们的页面如果存在异步请求然后再进行页面渲染的话,Google仍然是无法进行有效收录的。那么基于`Headless`的预渲染服务(Prerendering)又会如何呢?由于`Headless`需要启动完整的浏览器核心进行渲染,因此对于服务端性能是极大的消耗(尽管可以创造`Headless`的对象池进行重用,但渲染时性能仍然有较大的服务器端消耗)。针对较少的营销页面还可以,但是在需要整站SSR这个场景下,这并不能发挥很好的作用。 45 | 46 | 其次,特别是对于电商类产品而言,根据Amazon的页面加载延迟与收入关系的实践数据(每增加100ms网站加载延迟将导致收入下降1%),我们需要非常强力的支持进行首屏渲染的加速,降低内容到达时间(time-to-content),保证更好的用户体验和更高的用户留存。 47 | 48 | 与此同时,我们也应该支持非常灵活的页面可视化搭建平台来应付大量的日常化运营需求,并且也应该具有开放的能力和通用性,能很好的支撑公司其他团队的业务。在此背景下,我们稍加总结就能清晰的得到这套系统需要达成的目标,即: 49 | * 组件化及可视化 50 | * 能够进行SSR服务的渲染 51 | * 动态化,测试及发布不涉及核心的SSR渲染服务 52 | * 足够灵活,其他团队能很好的接入及使用 53 | * 能够保障服务安全,并做到业务隔离 54 | 55 | ### 技术选型 56 | 对于SSR服务而言,存在两种思路体系可以选择: 57 | 1. `HandleBars`等以纯字符串渲染引擎为主的思路 58 | 2. `Vue`/`React`等现代UI框架以Virtual DOM为主的思路 59 | 60 | `HandleBars`等字符串渲染引擎为主的思路优点在于:性能。但是对于现代的前端开发来说,难以地很好的利用`NPM`生态,对开发不是很友好。而以`Vue`/`React`等现代UI框架的Virtual DOM的思路,能够很好的利用生态,但是缺点也是相当明显的,即:由于编译阶段会产生非常多的Virtual DOM对象,因此在渲染性能和内存占用相比`HandleBars`等字符串渲染引擎的思路而言并不占优。 61 | 62 | 当然,在我们技术选型时,我们一般首先以**开发友好**为准则进行,毕竟效率即是一切。因此`Vue`/`React`的方案是我们理所当然会去选择的。但是,`Vue`/`React`等现代UI框架的所有出发点总归是以纯前端为主,后端渲染为辅的思路在做支持,因此其所包含的工具链(`Vue CLI`等)支持并不能很好的满足我们自身的需求,举个例子: 63 | 64 | > 团队A和团队B互不干涉的分别基于此系统开发两个项目C1与C2,此时,C1和C2项目都引用相同版本的诸如Vue、Lodash等公共依赖。现将C1与C2进行相关的同构打包,在不做编译工具调整的情况下,会产出D1和D1两个发布包。 65 | 66 | 我们可以看到,在这个例子之下,D1和D2必定会包含相同的公共依赖。这对于开放且动态化的SSR服务而言是极度不友好的,因为代码包体越大,无关代码越多,那么服务本地初始化的IO、CPU和内存占用成本势必会随之增加,这点我们在之后的《SSR服务篇》可以更深入地分析得到。其次,对于浏览器端的而言,因为静态资源的加载时间被增加了,也会增加更多的用户操作响应时间。考虑这样一个场景: 67 | 68 | > D1发布结果包含了100个组件,但是对于某一生成页面而言,只需要对2个组件进行重复渲染。 69 | 70 | 在这个场景中,不管是服务端还是浏览器端,我们都需要浪费大量的IO、CPU和内存在剩下的98个组件代码之中。当然,在这些场景里面,类似`Webpack`之类的编译工具仍然可以通过开发者标注`Dynamic Load`的方式来进行按需加载,但这也造成了非常大的开发负担,我们希望整个过程是编译工具自动完成的。因此,直接使用`Vue`/`React`等现代UI框架的现有工具链是完全无法满足我们的系统要求的。我们需要对现有工具链进行替代或改进。 71 | 72 | 总而言之,最后我们确定的技术选型为: 73 | * `Vue` 74 | * `ElementUI`(可视化后台所需) 75 | * 改进的编译工具(`sis`) 76 | 77 | 需要注意的是,**整个系统设计上实际与UI库/框架是无关的**,但我建议我们仍然需要在开发及生产期间固定你的技术选型,以此来避免因为无技术选型造成的项目不可维护及混乱,可以把这个看做是一个内部强制的约定。当然接下来的内容我们都会以`Vue`来进行讨论,如果有通用渲染服务的需求,可以在此基础上进行参考。 78 | 79 | 同时对于页面可视化系统来说,开发应该只是关注于组件的开发,而较少考虑外部系统的逻辑,因此我们一般采用如下的项目目录结构: 80 | 81 | ![目录结构](https://static.petera.cn/img1.png) 82 | 83 | 在这个基础上,我们所有最终的编译关注点应是在`components`目录,这是我们编译的目标目录。而其他文件主要是用作本地开发时所用,在最终的结果中并不引入。 84 | 85 | ### 资源的加载分析 86 | 在上面我们已经分析过,直接使用`Vue CLI`等工具链并不能很好的满足当前系统的需求,我们需要更灵活的**按需加载**的编译支持,并且希望这个支持是不需要开发干涉的。反观现有的编译工具而言,其更多是在部署之前进行相关的代码静态分析并整体打包。如果我们需要更灵活的**按需加载**,那么唯一的方式是在运行时能够获得当前页面所需要的组件代码然后整合后运行,如图: 87 | 88 | ![页面](https://static.petera.cn/img2.png) 89 | 90 | 从图中的逻辑我们可以看到,当页面仅需要ComponentA组件时,我们仅需要查询表中的ComponentA及其依赖的加载地址从而返回,然后由浏览器动态的完成整个加载,即可达成我们的要求。在整个过程中并不需要拉取ComponentB的代码,从而减少了加载和代码运行的耗时。 91 | 92 | 那么我们如何拿到这个依赖关系呢?实际上对于现代的编译工具而言,在进行编译时期就已经产出了对应的依赖关系了。我们只需要对其进行一些加工即可满足我们的需求。在编写`sis`的过程中,我会选择了 [Parcel](https://parceljs.org/) 来充当这一角色,其原因在于: 93 | * `Parcel`遵循0配置的原则,开箱即用,使用简单 94 | * `Parcel`有非常灵活的编译相关的接口,很容易进行编译工具的二次开发 95 | * `Parcel`默认编译即采用多核编译及产出编译Cache,编译速度极快 96 | 97 | `Parcel`相较于`Webpack`而言,在轻、重度使用上都会更胜一筹。 98 | 99 | > 其中最主要的原因是我比较喜欢的`Parcel`的`代码即配置`而不是`Webpack`的`配置优先`的原则。当然你也可以使用`Webpack`拿到相关的信息进行二次加工,其做法并无太大差异。 100 | 101 | 我们拿一个简单的`A.vue`代码举例,代码如下: 102 | ![代码](https://static.petera.cn/img10.png) 103 | 104 | 使用`Parcel`拿到相关的资源依赖关系十分简单,其代码如下: 105 | ![代码](https://static.petera.cn/img11.png) 106 | 107 | 其中`assets`是一个Bundler对象,其结构经过必要简化仅保留我们关心的数据后,大致如下(如需要更详细和准确的结构信息,请参考`Parcel`文档): 108 | ![代码](https://static.petera.cn/img12.png) 109 | 110 | 现在我们已经拿到了对应的依赖关系,接下来需要的工作就是将此树形结构的依赖转换成我们所期望的样子: 111 | ![代码](https://static.petera.cn/img13.png) 112 | 113 | 实际上,对于`sis`而言,其处理过程包含5个阶段,其分别是: 114 | 1. transform: 将`Parcel`的依赖数据JSON化,方便我们输出查看及调试 115 | 2. analysis: 分析对应的依赖结果,展平整个依赖数据,并且更改对应一些节点信息 116 | 3. checker: 检查对应代码是否符合强制的规范要求 117 | 4. optimize: 对依赖结果进行合并分析优化,并添加`AMD`模块化代码 118 | 5. output: 输出对应的代码及依赖关系JSON 119 | 120 | 在这里我们仅介绍5个阶段中的4个,而不对耦合具体业务需求的checker进行介绍。同时我们也主要以介绍思路为主,而简化了大部分的实现,实际上`sis`处理了非常多繁琐的Corner Case来保证编译的正确性。 121 | 122 | ### 简化资源结构 123 | 首先,为了调试的简便性,我们先将`Parcel`嵌套的Bundler对象简化为JSON数据,其代码如下: 124 | ![代码](https://static.petera.cn/img14.png) 125 | 126 | 经过此函数的处理,我们将Parcel的Bundler对象嵌套简化成了JSON数据,其结果为: 127 | ![代码](https://static.petera.cn/img15.png) 128 | 129 | 需要注意的是,这一步并不是必须的,如果为了编译时的性能,我们可以直接针对嵌套的Bundler对象进行后续处理。 130 | 131 | ### 展平资源结构 132 | 为什么需要将树形结构进行展平?其中的原因很简单的,就是为了后续更容易分析。在之后的optimize阶段我们需要大量的在依赖对象中进行跳转和改写,对树形结构展平,性能会更好,同时更容易达成这一目标。 133 | 134 | 将树形结构进行展平的代码很容易,代码如下: 135 | ![代码](https://static.petera.cn/img16.png) 136 | 137 | 此处`getVersion`函数作用是获取到当前依赖的版本号,其根据文件所在路径向上查找最近的`package.json`文件中的`version`字段获得。而`md5`函数是将对应的generated字段进行`JSON.stringify`后`md5`,然后返回一个7位长度的字符串。 138 | 139 | 通过此函数我们可以将对应的树形结构成功进行展平,其结果如下: 140 | ![代码](https://static.petera.cn/img17.png) 141 | 142 | 一切准备就绪,我们可以进入`sis`最有乐趣的optimize阶段了! 143 | 144 | ### 依赖的合并优化 145 | 146 | 回顾我们上面的讲述,为了保证可视化系统`按需加载`,我们依靠`Parcel`输出了依赖的JSON。从示例上我们可以看到,如果需要加载`A.vue`,那么我们只需要扫描整个对象,拿到`A.vue`、`lodash`、`Buffer`等代码的访问路径就行了。这看起来相当完美,但在实际应用中,这仍然远远不够。 147 | 148 | 我们知道对于浏览器来说,其静态资源的并发请求是存在限制的,在日常开发中我们并不会有这么简单的依赖关系。假如我们将`ElementUI`引入到A组件中然后编译,我们会发现,整个JSON文件有230多项依赖信息,即使我们在A组件中仅仅添加入一个`ElementUI`的`Button`组件,其所需要动态加载的文件数量就高达80多个!这显然不是我们想要的结果。针对这个问题我们会很自然地想到`合并`,现在的问题便会转化为:“我们如何知道哪些模块需要被合并呢?”。 149 | 150 | 假设我们有一个较为复杂的依赖关系,如图所示: 151 | 152 | ![依赖关系图1](https://static.petera.cn/img22222_meitu_5.png) 153 | 154 | 其中包含循环引用(G-F-E-G)以及自引用(E-E),那么我们怎么确定该合并哪些模块呢?当然,可能有读者会说:“ 你这个图错了,实际开发中不可能存在循环引用和自引用的!”。但事实上这在静态分析中是经常会发生的事情。 155 | 156 | 例如,当我们针对上面的简单示例使用`Parcel`编译后我们会发现,`Buffer`模块竟然自引用了自己!究其原因是因为`Parcel`为了统一Browser和Node端的代码而添加了`polyfill`代码。在编译时`Parcel`发现`lodash`引用的`Buffer`库中有这么一代码时: 157 | ![代码](https://static.petera.cn/img18.png) 158 | 159 | 就帮我们引入了`Buffer`的`polyfill`模块,从而造成了自引用。而至于循环引用,当我们编写了类似的如下代码: 160 | ![代码](https://static.petera.cn/img19.png) 161 | 162 | 不难发现,此中存在循环依赖为:C-B-A-C,但当我们使用Node执行此代码后会发现其能很正确的进行执行并输出: 163 | ![代码](https://static.petera.cn/img20.png) 164 | 165 | 从静态分析的角度来说,类似的代码在我们的依赖分析中很容易就会形成自引用与循环引用。针对以上问题,我们首先需要破坏依赖表中的自引用和循环引用(至于为什么这样做能够仍然保证正确的运行,就留给读者自行思考了)。其代码如下: 166 | ![代码](https://static.petera.cn/img21.png) 167 | 168 | 执行后,我们就进一步得到了如下的依赖关系: 169 | 170 | ![依赖关系图2](https://static.petera.cn/img4.png) 171 | 172 | 根据上图稍加分析我们可以知道,在这里的依赖关系中,F节点实际上可以与G节点合并生成G+F节点,完成一次合并。其合并的条件为F的**入度为1**,更简单的说法是,F的父亲节点唯一。因此我们可以得到第一次合并的结果: 173 | 174 | ![依赖关系图3](https://static.petera.cn/img5.png) 175 | 176 | 按照此规律我们依次进行合并,其过程如下: 177 | 178 | ![依赖关系图4](https://static.petera.cn/img6.png) 179 | 180 | 1. 将G+F节点与C节点合并(G+F节点入度为1) 181 | 182 | ![依赖关系图5](https://static.petera.cn/img7.png) 183 | 184 | 3. 将G+F+C节点与E节点合并(E节点入度为1) 185 | 186 | ![依赖关系图6](https://static.petera.cn/img8.png) 187 | 188 | 4. 将G+F+C+D节点与E节点合并,合并结束。 189 | 190 | 通过这个算法,我们可以将引用数为1的模块尽可能合并,以此减少静态资源加载所需的请求数。最终我们的JSON将会形成如下的形式: 191 | ![代码](https://static.petera.cn/img22.png) 192 | 193 | 其中我们对于每个合并的单节点新增了`pkg`字段,同时也形成了一个`__pkg0`节点记录了所有引用的模块。我们再次对相同的`ElementUI`项目进行测试,发现其依赖项从230项减少到了51项,大部分模块都被正确的合并了。 194 | 195 | 但与此同时我们也发现还有少部分模块因为被多个组件公用,从而被独立划分出来。当然此类文件也有很多显著的特点,例如:文件体积不大(大部分集中在1K以下),并且大部分都是非常基础的功能代码实现(如merge/filter/map等逻辑处理)。针对这些小文件,我们仍然可以在分析过程中再次进行合并。但在实际的`sis`实现中并没有这么做,而是选择使用Combo服务帮助我们完成了这部分功能,相关内容我们在《架构篇》会讲述。 196 | 197 | ### 给资源加上模块系统 198 | 199 | `Parcel`提供给我们的数据中,其`generated`包含了已经过babel等编译过的具体代码,其结构大致如下: 200 | ![代码](https://static.petera.cn/img23.png) 201 | 202 | 针对这些转移的代码,我们需要给其加上`AMD`的相关模块外层代码,例如: 203 | ![代码](https://static.petera.cn/img24.png) 204 | 205 | 同时,我们可以自行编写一个40行的AMD实现来完成模块的定义执行(当然你也可以使用开源的实现),其大体实现如下: 206 | ![代码](https://static.petera.cn/img25.png) 207 | 208 | 至此,所有模块内容的分析和改写工作就已完成,我们只需要根据JSON依赖表将各项代码输出到文件就完成了整个开发工具的工作。与此同时,JSON依赖表也应被同时输出,供我们之后的服务端和浏览器端进行运行时分析并提供加载依据。 209 | 210 | ### 更多功能 211 | `sis`作为一个开发工具,除了进行最终编译功能外,还应该增加一些方便开发的功能,如图: 212 | ![代码](https://static.petera.cn/33333.png ) 213 | 214 | 这些功能除了加快初始化项目的创建外,还沉淀了一些项目的**最佳实践**以便帮助新人能够更快的融入到项目开发之中。 215 | 216 | 217 | ### 总结与期待 218 | 219 | 在本篇中,我们详细讲述了项目的背景及对应的目标,并分析了如何依靠依赖表的方式优化对应的加载性能以及达到SSR目标中的动态化。当然整个过程我们主要是以讲述思想为主,抛开了非常多繁琐复杂的相关工作细节。 220 | 221 | 在下一篇文章中我们会继续详细讨论如何使用依赖表来进行SSR的**按需加载**的动态化,同时对`sis-ssr`和目前传统实现的SSR服务进行相关压力测试并分析相关的测试结果,从而清楚此种方式在单机性能上的边界。当然我们也对`sis-ssr`在可靠性上的提升做更多的介绍。敬请期待 《深入浅出动态化SSR服务(之二) - SSR服务篇》。 222 | -------------------------------------------------------------------------------- /深入浅出动态化SSR服务/深入浅出动态化SSR服务(三).md: -------------------------------------------------------------------------------- 1 | 深入浅出动态化SSR服务(之三) - 架构篇 2 | ---- 3 | 4 | ## 目录 5 | * 开发工具篇 6 | * SSR服务篇 7 | * 架构篇 🔥 8 | 9 | 在前面的《开发工具篇》及《SSR服务篇》中,我们已经能够开发出一个开发灵活、动态化、高性能、高稳定性的单机SSR服务了。但其中过于细节,还仍然不具备能够承担**工业级**之名。在现实中我们的系统设计一般是**自上而下**、**高屋建瓴**式地从更整体的架构层面来分析,如此层层递进,从宏观认知到微观认知、步步强化,进而得到一个优秀的系统。 10 | 11 | 在本章中我们将介绍动态化SSR服务的整体架构,希望能带领大家感受一下系统设计的有趣之处。当然,于我而言,系统设计只有适合与否之分,并无优劣之分,一个稳定可靠的、能够灵活拓展、满足需求的系统设计就是一个好的设计。此篇的目的主要是希望大家能从中举一反三,改变视角,深刻思考**前端工程化**的五个字的内涵从而得到提升。 12 | 13 | > 对于一个好的前端架构师而言,既需要更广泛的其他技术栈的认知(后端/运维/测试等),也需要能从代码出发,能够编写稳健可靠与高性能的代码。这也是我们强调良好的计算机学科基础的原因。 14 | 15 | ### 架构总览 16 | 17 | 此套动态SSR服务架构我们具有三个比较核心的诉求: 18 | 1. 高性能 19 | 2. 高稳定性 20 | 3. 秒级的部署和回滚 21 | 22 | 同时我们也很清楚地知道,对于前端而言不管是`HTML`、`CSS`还是`JavaScript`或是各种字体与图片,他们都是**资源**,因此整个的设计思路我们都是围绕**如何高效获取资源**来进行的。我们先简单的看一下架构的设计图,如下: 23 | 24 | ![架构图](https://static.petera.cn/www.png) 25 | 26 | 抛开部署部分,其一个请求的路径如下: 27 | 1. 客户端请求index.html页面 28 | 2. `CDN`回源至负载均衡服务(`LBS`) 29 | 3. `LBS`根据IP HASH的负载均衡算法将请求转发至某单台机中 30 | 4. 单台机中的`Nginx`检查是否命中`Cache`,若命中则直接返回,否则请求SSR服务 31 | 5. SSR服务从数据库或后端接口中获取得到对应的页面数据 32 | 6. SSR服务根据页面数据得到组件信息并对组件代码进行本地或远端的下载 33 | 7. SSR服务成功渲染,开始返回对应数据直至客户端 34 | 8. 客户端解析对应的`HTML`并从`CDN`中加载其他的静态资源 35 | 9. `CDN`回源Combo服务,并返回对应的静态资源数据 36 | 10. 客户端渲染页面并执行相关`JavaScript`代码,执行结束 37 | 38 | 接下来我们会介绍其中比较关键的几点。 39 | 40 | ### 抵抗单页面大流量 41 | 42 | 要抵抗单页面的大流量,首先我们自然而然会想到会使用缓存,与此同时我们也需要保证页面的及时响应,因此针对这个问题我们一般会使用`CDN`服务。由于`CDN`遵循**就近原则**,因此客户端请求对应的页面及其数据是会被自动分配到延迟最低的`CDN`节点上,如果我们正确的设置对应的HTTP相关缓存,是会得到很好的低延迟加载效果。 43 | 44 | 那么当临近`CDN`节点缓存失效怎么办呢?这个时候`CDN`根据对应配置也能很好的帮助我们尝试回源到目标服务器上,从而完成整个数据的加载。并且由于`CDN`到目标服务器上的网络一般采用更优化的网络链路,因此相对于客户端直接请求目标服务器来说,会有很大的低延迟优势(客户端到`CDN`的节点是最邻近的,`CDN`到目标服务器是更优化的网络链路)。 45 | 46 | 但需要注意的是,对于资源的回源HTTP缓存头设置我们一般需要让其遵循源站方便业务端控制。同时也需要将其设置为一个比较合理的数值,否则非常容易造成缓存长时间不失效的问题。 47 | 48 | ### 防止SSR服务被穿透 49 | 50 | 现在我们有了`CDN`帮助我们抵抗单页面的大流量后就可以高枕无忧了么?显然不是的,因为我们有提到,我们的`CDN`遵循的仍然是回源策略,由于`CDN`的多节点分布式特性,在缓存失效后仍然可能会有大量的回源服务器压力。在[Facebook Live的实践中](https://designingforscale.com/how-facebook-live-scales/),其穿透的请求数数据为**1.8%**。 51 | > However, this is still not enough for Facebook's reach. In fact, according to an article released by Facebook3, this architecture still leaks about 1.8% of requests to the Streaming Server. At their scale, with millions of requests, 1.8% is a huge amount to leak and puts a lot of stress on the Streaming Server. 52 | 53 | 单看这个数据并不大,但当其放在一个大并发量级下来说是十分可怕的。因此仅仅只有`CDN`这层缓存是完全不够足以保护我们的SSR服务不被流量穿透并压垮的。 54 | 55 | 为了防止此类Dog Pile问题,我们的架构中使用了`Nginx`的`Proxy Cache Lock`来进行保护。当启用这个配置时,按照`Proxy Cache Key`缓存元素的标识符,一次只允许一个请求转发传递给SSR渲染服务。同一缓存元素的其他请求将等待缓存中出现响应,或者释放该元素的高速缓存锁,直到`Proxy Cache Loke Timeout`指令设置的时间为止。 56 | 57 | 如此这般,在我们实际生产上到达真正到达SSR渲染服务的请求量非常低,从而有效保护了SSR服务被穿透造成的不稳定情况。 58 | 59 | ### 合并加载的资源请求 60 | 61 | 在《开发工具篇》我们较为详细的介绍了如何进行`sis`的**合并优化**,从对`ElementUI`的结果上来看,尽管我们将对应的对应的输出模块数量从230个降低到了51个,但实际上这也远远不够,仍然会有非常多零散小文件。 62 | 63 | 当然,我们在《开发工具篇》中提到,我们可以在编译期针对性的对这些小文件特征进行对应的合并优化,但在我们实际的系统里并有采取这些措施,而是选择了让Combo服务帮助我们完成了对应的操作。如图所示: 64 | 65 | ![combo](https://static.petera.cn/img1100.png) 66 | 67 | 我们可以看到实际上通过`sis-ssr`输出的加载路径都是以`/??`的方式进行打头,而在到达Combo服务之后其会帮助我们进行对应的合并操作。这样,不仅减少了编译时期的复杂,也达到了很好的减少浏览器静态资源请求数量的要求。 68 | 69 | > 但需要注意的是,对于URL长度来说,各个浏览器都有对应的限制,一般情况下生产下我们限制为1024是一个比较安全的值,关于这块的内容大家可以自行搜索相关的资料进行查询和确认。 70 | 71 | ### 秒级的发布与回滚 72 | 73 | 在此新架构之前,整个系统的发布和回滚是非常不可靠、缓慢且不灵活的,对于一次发布而言如果牵扯到非常多的子模块和子系统,那么耗时20多分钟是非常频繁的事情。由于新的架构是以平台化来进行设计,其希望能够接入更多的团队和项目产品,因此我们需要非常简便及可靠的发布逻辑作为支撑。 74 | 75 | 由于`sis`和`sis-ssr`所有组件相关信息都是以依赖表为基础,因此我们很容易在不涉及任何SSR服务下进行,其过程如下: 76 | 77 | 1. 将对应目标产物同步至Combo服务,若失败则直接阻断 78 | 2. 将对应目标产物同步至AWS S3中,若失败则直接阻断 79 | 3. 修改Database的相关表中依赖表的路径信息,失败则直接阻断 80 | 4. 刷新Redis的依赖表的路径信息,设置失效时间 81 | 82 | 同时,对于回滚来说,由于Combo服务和AWS S3对于各个时期的目标产物并不主动清除(一般保存一定数量的版本或是1-2个月的时长),因此当我们进行回滚操作时只需要将对应的依赖表信息修改至上一个版本的路径即可。 83 | 84 | ### 系统安全与运行隔离 85 | 86 | 既然作为一个开放的平台化系统,那么关于整体的系统安全也是需要考虑的一个重要问题。由于组件的开发可以被下发给其他团队,因此其中的不可控性非常的高,况且整个组件的开发还会涉及到SSR的服务端逻辑,那么如果写出风险性的代码是非常存在可能性的。试想,如果A团队编写的SSR端代码内部包含了了`process.exit(0)`,那么导致整个进程被重启,实际上会造成非常大的困扰。因此我们在运行过程中需要尽可能的做到运行隔离,限制SSR端代码可调用的基础库,保障整个系统的安全运行。 87 | 88 | `Node`实际上有非常多的沙盒实现可以使用,包括`Node`自身的`vm`模块,以及开源的`vm2`、`Safeify`等实现。但是这些实现由于都是基于`JavaScript`的特性来做到的,在实际中仍然有非常多可以破坏的方式,例如对于`vm`模块来说,我们可以编写如下的代码用来退出进程: 89 | ![代码](https://static.petera.cn/img999.png) 90 | 91 | 执行此代码可以很容易发现`Node`相关进程直接被成功退出了。那么还有更好的解决方案么?实际上由于在服务端,我们可以很容易引入V8来编写我们自己的类似`Node`的API子集运行环境,仅开放必要的模块,例如`http`等,同时制定严格的约定并在编译期进行检查。通过这种方式,在极小影响服务性能的情况下达到了运行隔离的要求,从而解决了系统安全的问题。 92 | 93 | ### 自动化测试 94 | 95 | 在整个架构中,测试也是我们必须考虑的一环。由于我所在的团队并没有测试,因此从我们自身的需求出发,实际上是需要尽可能的降低测试的成本。在现在的前端技术中,单向数据流已经是一个非常普遍以及常用的技术方案,`Vue`自身也有`Vuex`这样的单项数据流的管理方案。但由于可视化组件在我们自身的场景中一般比较轻量,使用类似`Vuex`这样的方案未免就有点杀鸡用牛刀了。除此之外,由于系统希望尽可能的开放给其他团队,能够让其他团队降低上手的成本,因此我们仅编写了一个简单的 [Store模式](https://cn.vuejs.org/v2/guide/state-management.html#%E7%AE%80%E5%8D%95%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86%E8%B5%B7%E6%AD%A5%E4%BD%BF%E7%94%A8) 提供使用。 96 | 97 | 由于单向数据流能够很轻易的获得组件的前后完整状态数据,因此其配合简单的录制工具以及`puppeteer`能够达到很好的节约测试成本的效果。 98 | 99 | ### 最后的最后 100 | 101 | 通过三篇关于`sis`、`sis-ssr`以及整体架构的介绍,我们逐渐清晰化了一个工业级的SSR服务的大致框架。希望大家通过阅读者三篇介绍后能够从中获得启发,体会到其中`前端工程化`的深刻含义,并把对应的思路带入到自己所在的项目当中,使其获得进步与提升。 102 | -------------------------------------------------------------------------------- /深入浅出动态化SSR服务/深入浅出动态化SSR服务(二).md: -------------------------------------------------------------------------------- 1 | ## 深入浅出动态化SSR服务(之二) - SSR服务篇 2 | 3 | ### 目录 4 | 5 | - 开发工具篇 6 | - SSR 服务篇 🔥 7 | - 架构篇 8 | 9 | ### 现代 SSR 之殇 10 | 11 | 在第一篇的技术选型中我们有说到,对于 SSR 的技术选型实际上有两个思路,其分别是: 12 | 13 | 1. `HandleBars`等以纯字符串渲染引擎为主的思路 14 | 2. `Vue`/`React`等现代 UI 框架以 Virtual DOM 为主的思路 15 | 16 | 虽然我们最后贯彻**开发友好**为准则选择了`Vue`/`React`等现代 UI 框架,但实际上其弊端(性能和内存占用)相对于字符串渲染引擎来说仍然有非常大的差距。从`Vue`的初始化过程我们很容易分析出,由于整个过程需要首先生成大量的 Virtual DOM 对象,然后再从 Virtual DOM 对象生成模板字符串,因此相比字符串渲染引擎来说避免不了会有更多的内存占用和 CPU 消耗。 17 | 18 | 这个问题在动态化的页面可视化 SSR 服务来说会更严重,考虑到直接使用`Vue CLI`同构并不进行优化的场景,对于普通页面可视化 SSR 服务来说,其开发编译和执行过程如下: 19 | 20 | 1. 我们存在一个服务的入口页面组件,其包含所有编写的组件,并通过`Vue`的``进行动态初始化,同时暴露此页面组件对象给外部调用 21 | 2. 使用`Vue CLI`进行打包,获得`entry-server.js` 22 | 3. 启动 SSR 服务,监听端口,准备接收请求进行渲染操作 23 | 4. 当请求到达时,我们请求需要渲染的页面数据信息,拿到当前页面所依赖的组件信息 24 | 5. 传递对应组件信息给入口页面组件,并使用`Vue`的`renderToString`方法获取得到对应的 HTML 字符串信息 25 | 26 | 从上面的过程我们可以看到,由于页面对应的组件是完全动态的,因此入口页面组件实际上需要注册所有已知的开发组件。`Vue`的初始化注册操作实际上是很耗时的,特别是在自身组件越来越多的情况下,而且对于某些简单的页面来说,这样的做法实际上会造成很多的性能浪费。考虑《开发工具篇》我们提到的一个场景: 27 | 28 | > D1 发布结果包含了 100 个组件,但是对于某一生成页面而言,只需要对 2 个组件进行重复渲染。 29 | 30 | 因此我们需要进行按需加载以及按需初始化注册,避免整体组件的加载及初始化注册,以此提高性能。 31 | 32 | ### 组件的按需加载 33 | 34 | 在《开发工具篇》中我们已经通过`sis`拿到了我们依赖关系表,同时也对每个组件的编译进行了拆分,因此要达到组件的按需加载需求实际上是比较容易的,如图所示: 35 | 36 | ![按需加载](https://static.petera.cn/img102.png) 37 | 38 | 其执行过程为: 39 | 40 | 1. 客户端请求对应的页面数据 41 | 2. 根据页面 ID 调用相关接口或数据库/缓存获取得到对应的页面与组件的数据信息(步骤 1-2) 42 | 3. 根据组件的信息查找依赖关系表,获取得到组件代码的加载路径(步骤 3-4) 43 | 4. 加载对应的加载路径,获取对应的组件代码并进行返回(步骤 5-6) 44 | 5. 动态执行对应的组件代码,并对`Vue`实例进行注册,并调用`renderToString`方法获取对应的 HTML 信息 45 | 6. 返回 HTML 信息给客服端 46 | 47 | 其用代码描述大致如下: 48 | ![代码](https://static.petera.cn/img117.png) 49 | 50 | 值得说明的是,由于`sis`及`sis-ssr`是以一个公共服务来进行地设计,各个团队对应的编译产物是存放在云端的对象存储之中。这样,其他团队使用此平台就不需要(也不应该)涉及到`sis-ssr`的部署及重启。因此,对于页面的组件代码获取而言是需要依靠`downloader`来进行本地/远程加载的。 51 | 52 | ### 组件代码的缓存 53 | 54 | 由于整个系统涉及到了组件代码的动态化加载,因此`downloader`的性能也会一定情况下影响单请求单页面的渲染信息返回速度。通过《开发工具篇》我们可以知道,使用合并优化后仍然会有一些公用依赖代码,但实际生产的运行过程中,公用代码的缓存使用率会比较高,因此这个时候我们可以在`downloader`内部增加缓存。另外,除了`Node`自身的内存缓存外,我们还可以增加一层文件缓存,尽可能的保证加载的性能(例如服务重启后的加载性能)。对于内存缓存而言,我们一般使用 LRU,以此来帮助我们主动清理 HIT 数量比较低的缓存内容,其代码如下: 55 | ![代码](https://static.petera.cn/img116.png) 56 | 57 | 当然,整个过程中你还可以使用`Promise.all`进行并行加载来进一步提高对应的组件代码的加载效率。 58 | 59 | ### 页面缓存 60 | 61 | 除了组件代码的缓存外,在正常的实际运行中,我们一般还需要增加页面的缓存,在这里我们同样使用`LRU`和文件缓存来达到这一目的,代码如下: 62 | ![代码](https://static.petera.cn/img115.png) 63 | 64 | ### 简单页面的压力测试 65 | 66 | 现在我们来对一个**Hello 页面**进行简单的压力测试,其渲染的组件代码为一个简单的子组件的嵌套,如代码所示: 67 | ![代码](https://static.petera.cn/img114.png) 68 | 69 | `sis-ssr`对比的实现为[vue2-ssr-example](https://github.com/csbun/vue2-ssr-example), 两者依赖库/框架均为: 70 | 71 | - `vue@2.6.11` 72 | - `vue-server-renderer@2.6.11` 73 | - `express@4.17.0` 74 | 75 | 测试的服务器配置为: 76 | 77 | - 阿里云 - 计算型 - 4 核 8G 78 | 79 | 注意,在此压力测试中`sis-ssr`去除了页面缓存,仅保留了组件代码的缓存,页面的组件信息获取写死在代码之中,两者的渲染执行逻辑基本一致。以`pm2 start index.js -i 4`的方式启动并进行压测,其最终结果为: 80 | ![简单压力测试](https://static.petera.cn/310_meitu_2.jpg) 81 | 82 | ### 结果分析 83 | 84 | 我们可以看到在总共 2000 个请求 300 个并发的情况下`sis-ssr`相比`vue2-ssr-example`的整体渲染性能会好很多,甚至高于 100%!可能有读者会有疑问:“按照分析来看,难道不是应该`vue2-ssr-example`性能会更高或者相差不大么?”,实际上确实应该如此,其拖慢`vue2-ssr-example`性能的最重要原因其实是`Webpack`编译时的逻辑。 85 | 86 | 我们知道对于`Webpack`等前端编译工具打包而言,其会按照对应的配置进行对应的代码`ES5.1`之类的语法编译及`polyfill`引入的优化,而对于`sis`来说,我们在打包 SSR 时并没有进行相关的优化,尽可能让对应的编译的代码结果足够干净,由于`sis-ssr`跑的代码大部分都是 Native 的语法和原生方法,相比`vue2-ssr-example`产出的代码来说会有不小的提升。 87 | 88 | 假设我们将这些部分进行类似`sis`的优化,那么实际上压力测试结果会比较相近。在**Hello 页面**的压测之中彼此的性能差距在 3%左右。 89 | 90 | > 从这个例子我们可以看出,对于性能优化而言是需要从细小处做起,多个细小处做到极致合起来就能得到意想不到的提升。 91 | 92 | ### 一般页面的压力测试 93 | 94 | 但在实际项目中,我们往往没有那么简单的页面,反而会有更复杂的组件嵌套以及调用关系。在这个压力测试中我们引入`ElementUI`中几个组件来进行比较,组件如代码所示: 95 | ![代码](https://static.petera.cn/img113.png) 96 | 97 | 注意,此次压力测试中我们仍然不修改`vue2-ssr-example`的相关编译配置,其最终测试结果为: 98 | ![简单压力测试](https://static.petera.cn/img112_meitu_1.jpg ) 99 | 100 | ### 结果分析 101 | 102 | 我们可以看到`sis-ssr`相比较于`vue2-ssr-example`来说仍然有 20%的性能提升,这是符合我们的预期的,因为从`ElementUI`编译得到的代码后我们可以看到,实际上`sis`引入的代码中有相当多的代码已经被预编译成`ES5.1`并加入了对应的`polyfill`了,所有的执行热点基本上是由于`ElementUI`自身逻辑造成的,因此`sis-ssr`相对`vue2-ssr-example`的提升就不会像**Hello 页面**那么明显了。 103 | 104 | 总之,基于`sis`和`sis-ssr`的 SSR 服务在实际生产的页面渲染场景相对于目前大部分其他实践方案来说是有比较可观的性能收益的。 105 | 106 | > 这里的`vue2-ssr-example`的压测结果相比**Hello 页面**测试结果来说降低得并不是特别多,其原因在于:实际上我们引入的`ElementUI`组件还是比较简单的组件,相比**Hello 页面**而言增加的执行逻辑并没多太多,但`sis-ssr`因为以上讨论原因下降会比较明显。以上内容均可以通过`Node`的`Profile`工具分析得出。 107 | 108 | ### 超时、限流与降级 109 | 110 | 在实际生产中,服务高性能当然是值得高兴,但如果是没有稳定性的高性能,那么实际上就没有那么让人愉悦了。在`sis-ssr`中为了保证对应的单机服务的稳定性,我们分别采取了三个策略,其分别是: 111 | 112 | - 超时策略 113 | - 限流策略 114 | - 渲染降级策略 115 | 116 | 在`sis-ssr`中实际上会出现不少的异步请求的情况,例如`downloader`的远程加载组件代码。对于这些服务端的异步请求,我们一般都会强制考虑请求的超时处理,防止请求长时间被挂起造成的问题,例如: 117 | ![代码](https://static.petera.cn/img200.png) 118 | 119 | 其次,为了保证我们的单机服务不被外部流量冲击,我们也需要加入限流的策略。其中比较常用的限流策略包括:固定窗口算法、滑动窗口算法、漏桶算法以及令牌桶算法等。在`Node`中有存在基于`redis`的`express-rate-limit`等`NPM`包帮助我们完成相关的逻辑。 120 | 121 | 最后`sis-ssr`为了应对各种请求/流量异常的情况还做了多级的降级策略: 122 | 1. 服务端数据请求成功且`Node`端渲染成功,正常返回 123 | 2. 服务端数据请求成功但`Node`端渲染失败,则抛弃首屏并将相关数据写入页面,由客户端进行渲染 124 | 3. 流量异常/系统资源占用过高,完全由客户端请求数据并进行渲染 125 | 126 | 至此由上至下进行兜底,以此保证服务的可用性。除此之外,由于`Node`的单进程单主线程(在这里我们排除I/O异步等事件循环中的子线程)且页面渲染是纯CPU操作的特性,其在渲染大页面时经常会出现阻塞运行时主线程的情况。因此我们可以创建包含一定数量工作线程的线程池(使用`Node`的`worker_thread`),然后将对应的页面渲染放置在`Worker`工作线程之中,当线程池中无空闲工作线程时,Master线程进行主动的页面降级渲染以此来提高对应的性能。此功能已在`sis-ssr`中得到了实现并取得了极好的实际效果,其压力测试结果如图所示: 127 | 128 | ![压力测试](https://static.petera.cn/img440.png) 129 | 130 | 此次压测的各环境不变,同时测试对象主要是**Hello 页面**。从结果中我们可以看到,搭配了主动降级+`Worker`的渲染方式相比后两者有非常大的提升,其原因也很简单,因为大部分请求由于`Worker`不空闲均被降级为牺牲首屏并将数据写入页面,由客户端渲染的方式了(在上面的压力测试下,大约有10%左右的请求是真正的`Node`渲染,其余的都走降级页面了)。 131 | 132 | 在这种情况下,由于Google已经支持同步的前端渲染页面的收录,所以降级渲染请求并不会影响到SEO。那么对于内容到达时间(time-to-content)呢?我们可以做一个粗糙的计算(实际请以数据日志为主),在我们**只关注于渲染时间且单机单进程**情况下,假设并发10个请求,Node端每次渲染耗时100ms,那么完全以Node端渲染来说,最后一个用户的内容到达时间为1s。若考虑降级,降级页面的渲染时间为50ms,则最后一个用户获得页面HTML的时间为350ms,若静态资源加载加上同步的前端渲染页面耗时小于650ms,则相对于完全Node端渲染的方案有提升。同时我们需要注意,随着并发数的增加,此提升会越来越高。若并发数提升为20时,同样的计算后我们可以得到完全Node渲染的方式最后一个用户的内容到达时间为2s,而降级页面最后一个用户获得页面HTML的时间为750ms,若静态资源加载加上同步的前端渲染页面耗时小于1250ms则具有对应的提升。 133 | 134 | ### 总结与期待 135 | 136 | 在本章我们较为详细的探讨了`sis-ssr`的一些内部逻辑,同时与`vue2-ssr-example`进行了单机的压力测试的比较并分析了对应的原因。同时我们也讲述了`sis-ssr`是如何保证生产环境的高可用性的。在下一篇中我们会从这些细节脱身,从更全局和整体的架构角度来看待整个动态化SSR服务。敬请期待《深入浅出动态化SSR服务(之三) - 架构篇》。 137 | -------------------------------------------------------------------------------- /高性能WASM播放器实现/images/pic1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/高性能WASM播放器实现/images/pic1.gif -------------------------------------------------------------------------------- /高性能WASM播放器实现/images/pic2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/高性能WASM播放器实现/images/pic2.png -------------------------------------------------------------------------------- /高性能WASM播放器实现/images/pic3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/高性能WASM播放器实现/images/pic3.png -------------------------------------------------------------------------------- /高性能WASM播放器实现/images/pic4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/高性能WASM播放器实现/images/pic4.png -------------------------------------------------------------------------------- /高性能WASM播放器实现/images/pic5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ErosZy/md/f2e55a54d030737f78fa5ee11c4e4db10fe7eeb2/高性能WASM播放器实现/images/pic5.png -------------------------------------------------------------------------------- /高性能WASM播放器实现/高性能WASM播放器实现.md: -------------------------------------------------------------------------------- 1 | ## WXInlinePlayer:高性能WASM播放器实现 2 | 3 | ### 缘起 4 | 随着直播和短视频的兴起,视频由于承担了更大的信息量,因此现在已经是非常主流的运营/产品信息输出方式,其中包括短视频、直播等。但目前我们所能看到的相关产品的具体实现形式主要都是Native的方式,Web相关的类似形式不管在性能抑或体验上都有非常大的差距。其中最重要的原因是国内各个浏览器厂商自身的利益关系所在,他们对HTML5的Video能力做了非常多的限制,不限于: 5 | * 禁止自动播放; 6 | * 播放器魔改为原生播放器,不可进行HTML相关元素层叠; 7 | * 播放前后硬插广告内容; 8 | * 视频自动置顶; 9 | * 相关API和事件与标准实现不统一; 10 | * ...... 11 | 12 | 相关的问题在许多文章中都有对应的描述和涉及,也给出了非常多hack的方式试图绕过相关的限制已达到产品目的。但由于主动权完全掌握在浏览器平台方手上,因此这些hack手段失效非常快,极度的不可靠。 13 | 14 | 其中比较有代表性的是腾讯IMWeb团队写的[《复杂帧动画之移动端Video采坑实现》]()。我们可以看到对于复杂的帧动画而言,传统的动画技术方案例如:Lottie、Vivus等,由于技术上的原因是无法完全复原复杂帧动画效果的,唯独视频这一方式,不管在文件体积、渲染性能以及设计自由度上都有无可比拟的优势。但视频方案在HTML5上却遭受了如上所述的非常多的阻力。 15 | 16 | 为了解决这一系列的问题,依靠在WASM和音视频上的浅薄积累,我编写了[WXInlinePlayer](https://github.com/qiaozi-tech/WXInlinePlayer)来解决相关的问题,目前有非常多的安防监控、直播及短视频公司使用[WXInlinePlayer](https://github.com/qiaozi-tech/WXInlinePlayer)完成了自己的业务开发以及解码内核的定制化,并取得了非常好的线上效果。目前WXInlinePlayer的使用场景包括: 17 | * 沉浸式短视频产品(Web版类抖音产品) 18 | * H5复杂视频动画播放 19 | * 无插件化安防监控 20 | 21 |
22 | 23 |

使用WXInlinePlayer实现类抖音产品

24 |
25 | 26 | ### 方案比较 27 | 28 | 在WXInlinePlayer诞生之前,实际上也有非常多的个人及公司做了相关类似实践,包括不限于: 29 | * [基于WASM的H265播放器及在NOW直播中的应用](https://developer.aliyun.com/article/747663) 30 | * [花椒前端基于WASM的H265播放器研发](https://cloud.tencent.com/developer/article/1467813) 31 | * [手淘Web端H265播放器研发解密](https://fed.taobao.org/blog/2019/03/19/web-player-h265/) 32 | * [基于WASM的开源H265播放器 - WasmVideoPlayer](https://github.com/sonysuqin/WasmVideoPlayer) 33 | * ...... 34 | 35 | 对于这些实践而言我们可以看到其具有两个共性: 36 | 1. 基本上都是基于FFmpeg的裁剪及编译实现 37 | 2. 主要是为了解决浏览器的H265播放问题 38 | 39 | 但在实际的使用中,以FFmpeg的裁剪及编译实现实际上是有其自身的局限性及问题的。对于Web这种非常资源大小敏感的环境,使用FFmpeg的裁剪及编译获得的相关WASM以及包装文件大部分都有超过1M以上的大小,即使使用GZIP等压缩手段也会超过500K以上。其次这种方案非常难以被优化,由于最新的FFmpeg包含非常多的部件,修改及优化的成本非常之高,例如想增加多WebWorker解码的能力来提升解码的性能,对于FFmpeg方案来说修改成本非常之大。 40 | 41 | 因此,在编写WXInlinePlayer之初,我就选择了另一条更轻量化更易扩展的道路:编解码库插件化。 42 | 43 |
44 | 45 |

独立解码依赖

46 |
47 | 48 | 通过这种方式,WXInlinePlayer可以根据使用情况选择合适的解码插件,同时针对各个解码插件进行针对性的体积优化和性能提升,例如我们使用H265相关实现来作比较: 49 | 50 |
51 | 52 |

文件体积为WASM与相关JS文件总和

53 |
54 | 55 | 可以看到,分离后的WXInlinePlayer解码器在压缩后相比其他实现有接近5倍的文件体积的减小。同时,WXInlinePlayer也依靠更轻量的解码和渲染架构,在H265的播放整体性能与内存占用上相比其余实现也有30%以上的提升: 56 | 57 |
58 | 59 |

性能测试

60 |
61 | 62 | 在使用花椒直播线上同一H265的FLV格式流的测试中,WXInlinePlayer在性能上相较于其他FFmpeg的实现都有比较显著的CPU和内存占用的提升。 63 | 64 | ### 整体逻辑 65 | 66 | 实际上现在基于WASM的播放器在架构实现上基本上都趋同,主要分为几层: 67 | * I/O层:通过XHR或者WebSocket进行音视频数据的获取; 68 | * 解码层:解码获取YUV图像数据及PCM音频数据 69 | * 音画同步及缓冲区层:通过音视频时间戳进行对齐及缓冲部分播放数据 70 | * 绘制层:通过Canvas或WebGL进行绘制 71 | * 音频播放层:通过AudioContext进行音频播放 72 | 73 | 其逻辑用图表示如下: 74 |
75 | 76 |

整体逻辑图

77 |
78 | 79 | 在实际的编写过程中,为了更高的性能,我们一般会将I/O层和解码层放置在不同的WebWorker之中以此来提高性能。其音画同步和缓冲区层,对于WXInlinePlayer而言是放置在了一个名为Processor的对象中统一进行了处理。而绘制层为了进一步提升性能,使用WebGL进行了YUV420到RGB的转换以及绘制工作。 80 | 81 | 整体的逻辑从上面的描述来看并不是非常复杂,但是由于Web的一些功能缺失,要完整实现一套可用的WASM播放器还是存在一些挑战: 82 | 83 | #### 兼容性问题 84 | 85 | WASM的兼容性在目前而言已经有了比较大的改观,但是在移动端而言,由于国内各个浏览器厂家的实现并不统一,因此需要考虑WASM不可用的情况。在WXInlinePlayer中,为了更好的应对兼容性相关的问题,解码库编译出了ASM.JS及WASM两个版本,同时提供了降级的判断,方便使用中进行对应的处理: 86 | ```javascript 87 | if (WXInlinePlayer.isSupport()) { 88 | WXInlinePlayer.init({ 89 | asmUrl: './prod.all.asm.combine.js', 90 | wasmUrl: './prod.all.wasm.combine.js' 91 | }); 92 | 93 | WXInlinePlayer.ready().then(() => { 94 | const player = new WXInlinePlayer({ 95 | url: '......', 96 | $container: document.getElementById('container'), 97 | hasVideo: true, 98 | hasAudio: true, 99 | volume: 1.0, 100 | muted: false, 101 | autoplay: true, 102 | loop: true, 103 | isLive: false, 104 | chunkSize: 128 * 1024, 105 | preloadTime: 5e2, 106 | bufferingTime: 1e3, 107 | cacheSegmentCount: 64, 108 | customLoader: null 109 | }); 110 | }); 111 | } 112 | ``` 113 | 114 | 目前WXInlinePlayer兼容性已经覆盖主流浏览器: 115 | * Android 5+ 116 | * iOS 10+ (含Safari及WebView) 117 | * Chrome 25+ 118 | * Firefox 57+ 119 | * Edge 15+ 120 | * Safari 10.1+ 121 | 122 | #### 解码性能不足及卡顿 123 | 124 | 尽管WASM相比于JavaScript在性能上有非常大的提升,但是由于目前WASM的优化手段比较有限,像SIMD、多线程等支持都还存在问题,因此在播放部分高清视频的时候难免会造成解码性能不足卡顿的情况(对于移动端而言更明显)。在生产环境中,WXInlinePlayer的卡顿和延迟主要来自于3个地方: 125 | * 网络加载的延迟 126 | * 软解码的延迟 127 | * 渲染的延迟 128 | 129 | 一般来说,如果在用户网络环境较好的情况下,渲染由于使用了WebGL,很难造成瓶颈(操作很单一),其中一般会因为软解码性能不足造成不停卡顿及延迟。 130 | 131 | 优化因为软解码性能不足造成的延迟,我们一般从几个地方着手: 132 | * 视频的profile:相比于main/high而言,baseline不包含B帧,解码消耗更低; 133 | * 视频帧率:过高的帧率会造成软解码跟不上,可以试着降低帧率,例如24fps; 134 | * 视频码率:码率越高,视频富含的细节越多,也越清晰,但是会消耗更多的解码性能,可以试着降低码率; 135 | * 视频分辨率:过高的视频会造成单帧传递的数量极大; 136 | 137 | 目前WXInlinePlayer在中高端机上解1280x720,码率1024,帧率24fps的视频比较流畅。除此之外,WXInlinePlayer也提供了performance事件帮助使用者了解当前的解码性能,便于提示用户降级到后备方案: 138 | ```javascript 139 | player.on('performance', ({cost, average})=>{ 140 | const prop = cost / average; 141 | if(prop >= 2.0){ 142 | console.log('good performance'); 143 | }else if(prop < 2.0 && prop >= 1.0){ 144 | console.log('ok, thats fine'); 145 | }else{ 146 | console.log('bad performance'); 147 | } 148 | }); 149 | ``` 150 | 151 | #### 自动播放 152 | 153 | 由于目前现代浏览器已经遵循[Autoplay Policy Changes](https://developers.google.com/web/updates/2017/09/autoplay-policy-changes),因此在非用户主动触发播放或者MEI得分及权重较低的情况下是无法自动播放的。但WXInlinePlayer仍然对微信WebView以及无音频文件播放的场景下提供了自动播放的能力。 154 | 155 | #### 直播支持 156 | 157 | 直播由于良好的互动性,因此被非常多的产品集成及使用,对于一个播放器而言,直播能力的支持也是必不可少的。在音频播放这块我们一般使用AudioContext,但可惜的是,目前而言AudioContext还无法很好的支持LiveStream的音频解码能力,因此WXInlinePlayer使用了[Multi Source Nodes](https://github.com/qiaozi-tech/WXInlinePlayer/blob/master/src/sound/browser.js#L219)的方式来进行了音频LiveStream的支持,当然,这块也可以进一步优化,预计后期将会提升为AudioWorklet的方式来进行相关支持。 158 | 159 | ### 更多 160 | 161 | 未来随着5G及更高性能的硬件设备的发展WASM会逐渐渗透在多个领域。浏览器依托WASM技术必将会为未来扩展音视频处理的通用能力提供了想象的空间。目前WXInlinePlayer已经更新到了1.3.3版本,随着越来越多公司的使用,WXInlinePlayer在现版本已经逐步稳定。在后期中,我们将进一步尝试增强WXInlinePlayer的性能和易用性,包括不限于: 162 | * 重构解码器,精确缓存帧数据; 163 | * 增加OffscreenCanvas的支持,提升性能和减少内存占用(Chrome 69+); 164 | * 提供默认的播放器UI; 165 | * 新增H264/H265的SIMD支持; 166 | * 支持多Worker的GOP并行解码,提升软解性能; 167 | * 进一步降低直播延迟; 168 | * ...... 169 | 170 | 后续还有很多继续优化和深入的点,对相关知识感兴趣的同学欢迎沟通交流。 171 | --------------------------------------------------------------------------------