这两年,WebAssembly(简称WASM)越来越普及了。
从浏览器端的高性能计算,到服务端的Serverless,再到边缘计算、插件系统,WebAssembly的应用场景越来越多。它的跨平台、高性能、安全沙箱等特性,让它成为了很多场景的首选技术。
我所在的团队,最近也在一个项目中引入了WebAssembly。这个项目,是一个在线的图像处理工具,以前是纯JavaScript实现的,性能很差,处理一张高清图片要好几秒,用户体验很不好。我们决定用WebAssembly来重构,把核心的图像处理逻辑用C++实现,编译成WASM,在浏览器里运行。
重构的过程,不是简单地把JavaScript代码翻译成C++代码。我们发现,原来的JavaScript代码,有很多问题,结构混乱,逻辑不清,性能很差。如果直接翻译过去,只是把烂代码从一种语言搬到了另一种语言,不会有本质的提升。
所以,我们决定借着这次WebAssembly重构的机会,把代码彻底重写,从烂代码变成优雅代码。重构完成之后,性能提升了十几倍,代码也变得清晰、易读、易维护。
这篇文章我想分享一下这次代码重构的经验。从性能瓶颈分析、WebAssembly集成方案到代码结构优化、优雅代码设计,通过实际案例,展示如何把烂代码重构成优雅、高效、可维护的代码。如果你也在做WebAssembly相关的开发,或者正在进行代码重构,希望这篇文章能给你一些参考。
项目背景和问题分析
先说说项目的背景和遇到的问题。
这个项目,是一个在线的图像处理工具,用户可以在浏览器里上传图片,然后进行各种处理,比如滤镜、裁剪、缩放、旋转、调色等。所有的处理,都是在浏览器端用JavaScript完成的,不需要上传到服务器,保护用户隐私。
项目最开始的时候,功能比较简单,只有几个基础的滤镜,JavaScript实现还能应付。但随着功能越来越多,图像处理的算法越来越复杂,JavaScript的性能问题就暴露出来了。
主要的问题有以下几个。
第一,性能差,处理速度慢。
JavaScript是解释型语言,虽然现在有JIT编译,性能已经比以前好了很多,但和原生代码比起来,还是有很大的差距。特别是对于计算密集型的任务,比如图像处理,JavaScript的性能就不够用了。
比如,一个简单的高斯模糊滤镜,处理一张1920x1080的图片,JavaScript需要2到3秒。如果是更复杂的滤镜,比如双边滤波、内容感知缩放,需要十几秒甚至几十秒。用户上传一张图片,要等好几秒才能看到效果,体验很差。
第二,代码结构混乱,难以维护。
这个项目,是几个开发者在不同的时期迭代出来的,没有统一的架构和规范。代码结构很混乱,一个几千行的文件,里面什么都有,图像处理算法、UI逻辑、数据处理,都混在一起。
函数的命名也很随意,有的用拼音,有的用英文缩写,有的根本看不懂是什么意思。函数的参数也很多,有的函数有七八个参数,调用的时候很容易传错。代码里还有很多重复的逻辑,同样的图像处理算法,在好几个地方都有实现,而且实现还不一样。
这样的代码,维护起来很痛苦。改一个功能,要找半天才能找到相关的代码;改了一个地方,可能会影响到其他地方;想加一个新功能,不知道该往哪里加。代码的可读性和可维护性都很差。
第三,算法实现不优,有很多性能浪费。
原来的JavaScript代码,很多算法实现得很粗糙,没有做优化。比如,图像处理的时候,用的是最朴素的实现,没有利用缓存,没有做向量化,没有做并行化。很多地方,还有重复计算,比如同一个像素的值,在好几个地方都重新计算了一遍。
还有一些地方,用了不必要的中间变量和中间数组,增加了内存的占用和GC的压力。JavaScript的GC,在处理大数组的时候,会有明显的停顿,影响用户体验。
这些问题,都导致了性能的浪费。本来可以更快的,但因为算法实现不优,速度慢了很多。
第四,缺乏测试,质量没有保障。
原来的代码,几乎没有测试。所有的功能,都是开发者手动测试的,改了代码之后,手动点一遍,看看有没有问题。这样的测试方式,覆盖不全,很容易引入bug。
而且,因为没有测试,重构的时候也很没有安全感。改了代码,不知道会不会影响其他功能,不敢大胆地改。这也是代码越来越烂的原因之一,因为没人敢重构,只能在原来的基础上继续堆代码。
基于这些问题,我们决定借着引入WebAssembly的机会,把代码彻底重构一遍。目标是:性能提升十倍以上,代码结构清晰,易读易维护,有完善的测试。
WebAssembly技术方案选型
在开始重构之前,我们先做了WebAssembly的技术方案选型。
WebAssembly的开发,有几种不同的技术路线,每种路线有不同的优缺点。
第一种路线,是用C/C++开发,然后用Emscripten编译成WebAssembly。
这是最传统、最成熟的路线。C/C++的性能最好,生态最完善,有很多现成的库可以用。Emscripten工具链也很成熟,能把C/C++代码编译成WASM,还能自动生成JavaScript的绑定代码。
这种路线的优点是性能好,生态完善,工具链成熟;缺点是需要掌握C/C++,开发门槛相对高一些,编译过程比较复杂。
第二种路线,是用Rust开发,然后用wasm-pack编译成WebAssembly。
Rust是这两年很火的系统级编程语言,它的性能和C/C++相当,但内存安全性更好,没有空指针、数据竞争等问题。Rust的WebAssembly工具链也很完善,wasm-pack能很方便地把Rust代码编译成WASM,并生成JavaScript绑定。
这种路线的优点是性能好,内存安全,工具链现代化;缺点是Rust的学习曲线比较陡,需要掌握所有权、生命周期等概念。
第三种路线,是用AssemblyScript开发,然后编译成WebAssembly。
AssemblyScript是一种基于TypeScript语法的语言,专门用来开发WebAssembly。它的语法和TypeScript几乎一样,前端开发者很容易上手。
这种路线的优点是学习成本低,前端开发者容易上手,和JavaScript生态结合紧密;缺点是性能比C/C++和Rust稍差一些,生态还不够完善,复杂的项目可能不够用。
第四种路线,是用Go、Swift、Kotlin等语言开发,然后编译成WebAssembly。
这些语言,也都支持编译成WebAssembly,但工具链还不够成熟,性能也不如C/C++和Rust,用的人相对少一些。
经过评估,我们选择了第一种路线,用C++开发,用Emscripten编译。原因是:第一,我们团队有C++的开发经验;第二,图像处理领域,C++有很多成熟的库,比如OpenCV,可以直接用;第三,Emscripten工具链最成熟,文档最完善,遇到问题容易解决。
当然,Rust也是一个很好的选择,如果团队有Rust经验,或者对内存安全要求很高,Rust会更合适。AssemblyScript适合简单的项目,或者团队只有前端开发者的情况。
技术方案确定之后,我们就开始了代码重构。
架构设计:分层和解耦
重构的第一步,是重新设计架构。原来的代码,所有东西都混在一起,没有分层,没有解耦。我们要把代码重新分层,让各个部分各司其职,互相解耦。
我们把整个系统,分成了几个层次。
第一层是核心算法层。
这一层,是纯C++实现的,包含所有的图像处理算法,比如滤镜、裁剪、缩放、旋转、调色等。这一层,不依赖任何UI,不依赖任何JavaScript,只负责纯粹的计算。输入是图像数据和参数,输出是处理后的图像数据。
这一层的设计,遵循几个原则:一是函数单一职责,每个函数只做一件事;二是无副作用,函数不修改外部状态,只根据输入计算输出;三是接口清晰,函数的参数和返回值都很明确,容易理解和使用。
把核心算法独立出来,有几个好处:一是可以单独测试,不需要UI就能测试算法的正确性;二是可以复用,同样的算法,可以在不同的地方调用;三是性能优化方便,只需要优化这一层的代码,就能提升整体性能。
第二层是WASM绑定层。
这一层,是用Emscripten的API,把C++的核心算法,暴露给JavaScript调用。它负责JavaScript和C++之间的数据转换,比如把JavaScript的TypedArray转换成C++的指针,把C++的返回值转换成JavaScript能用的数据。
这一层,很薄,只做数据转换和接口暴露,不包含任何业务逻辑。它的作用,就是让JavaScript能方便地调用C++的算法。
Emscripten提供了很多方便的API,比如EMSCRIPTENBINDINGS,可以很方便地把C++的函数和类绑定到JavaScript。还有cwrap、ccall等API,可以直接调用C函数。我们用的是EMSCRIPTENBINDINGS,因为它更现代化,类型更安全。
第三层是JavaScript服务层。
这一层,是用JavaScript封装的,对上层提供统一的图像处理服务接口。它负责调用WASM绑定层的方法,处理异步逻辑(因为WASM的加载是异步的),处理错误,做一些数据的预处理和后处理。
这一层,对上层隐藏了WASM的细节。上层不需要知道底层是用WASM实现的,还是用JavaScript实现的,只需要调用统一的接口就行。这样,如果以后要换底层实现,比如从WASM换成WebGPU,只需要改这一层,上层不需要改。
第四层是UI层。
这一层,是用户界面,负责和用户交互,接收用户的操作,展示处理的结果。它调用JavaScript服务层的接口,来处理图像,然后把结果展示给用户。
UI层,只负责UI逻辑,不包含任何图像处理的算法。这样,UI和算法就解耦了,改UI不会影响算法,改算法也不会影响UI。
通过这样的分层设计,整个系统的结构就清晰了。每一层都有明确的职责,层与层之间通过清晰的接口通信,互相解耦。这样的代码,易读、易维护、易测试。
核心算法层的优雅设计
架构设计好了之后,我们开始写核心算法层的代码。这一层,是整个系统的核心,也是性能的关键。我们在写这一层代码的时候,特别注意代码的优雅性和性能。
先说说代码的优雅性。
第一,命名清晰。
变量名、函数名、类名,都用清晰、准确、有意义的名字。不用缩写,不用拼音,不用单个字母(除了循环变量i、j等)。比如,表示图像宽度的变量,叫imageWidth,而不是w;表示高斯模糊的函数,叫gaussianBlur,而不是gb。
清晰的命名,能让代码自文档化,看名字就知道是做什么的,不需要额外的注释。
第二,函数短小,单一职责。
每个函数,都尽量短小,只做一件事。如果一个函数太长,或者做了好几件事,就把它拆分成几个小函数。比如,原来的一个大函数,里面包含了图像加载、参数校验、算法处理、结果返回,我们把它拆成了loadImage、validateParams、processImage、returnResult四个小函数。
短小的函数,容易理解,容易测试,也容易复用。每个函数都有一个清晰的名字,调用的时候,就像在读一句话一样,很容易理解整个流程。
第三,参数少,用结构体传递。
原来的JavaScript代码,很多函数有七八个参数,调用的时候很容易传错,也很难记住每个参数的意思。我们在C++里,用结构体来传递参数。比如,高斯模糊的参数,有半径、标准差、边界处理方式,我们把它们封装成一个GaussianBlurParams结构体。
这样,函数的参数就变少了,一般只有一两个:图像数据和参数结构体。调用的时候,先构造参数结构体,再传给函数,很清晰,不容易传错。而且,以后要加参数,只需要在结构体里加字段,不需要改函数签名,兼容性更好。
第四,避免重复代码。
原来的代码,有很多重复的逻辑,同样的算法在好几个地方都有实现。我们把重复的代码抽出来,做成公共的函数或者类。比如,图像的边界处理,在很多滤镜里都用到,我们把它抽成一个单独的函数,所有滤镜都调用这个函数。
避免重复代码,不仅减少了代码量,也提高了可维护性。以后要改边界处理的逻辑,只需要改一个地方,所有用到的地方都生效。
第五,用RAII管理资源。
C++里,资源的管理是一个很重要的问题。用RAII(Resource Acquisition Is Initialization)的方式,把资源的生命周期和对象的生命周期绑定起来,对象创建的时候获取资源,对象销毁的时候释放资源。这样,就不会忘记释放资源,也不会出现资源泄漏。
比如,图像数据的内存,我们用std::vector来管理,vector销毁的时候,自动释放内存。文件句柄,用std::ifstream,离开作用域的时候自动关闭。这样,就不需要手动调用free、delete、close,代码更安全,也更简洁。
再说说性能优化。
第一,利用缓存,提高内存访问效率。
图像处理,是内存密集型的操作。内存访问的效率,直接影响性能。我们在写算法的时候,特别注意缓存的利用,尽量让内存访问是连续的,避免跳来跳去。
比如,遍历图像像素的时候,按行遍历,而不是按列遍历。因为图像在内存里是按行存储的,按行遍历,内存访问是连续的,缓存命中率高;按列遍历,内存访问是跳的,缓存命中率低,性能差很多。
还有,把常用的数据放在局部变量里,避免反复访问内存。比如,图像的宽度和高度,在循环里经常用到,我们把它们存到局部变量里,而不是每次都从对象里取。局部变量会被编译器放到寄存器里,访问速度比内存快很多。
第二,减少重复计算。
很多算法里,有一些值是固定的,或者在循环里不会变的。我们把这些值提前计算好,存在变量里,避免在循环里重复计算。
比如,高斯模糊的卷积核,是固定的,我们提前计算好,存在数组里,循环的时候直接用,而不是每次都重新计算。还有,一些数学函数的结果,比如sin、cos、exp,如果参数是固定的,提前计算好,存在变量里。
减少重复计算,能显著提升性能。特别是在嵌套循环里,一次重复计算,可能会被执行几百万次,累积起来,影响很大。
第三,用SIMD指令做向量化。
C++里,可以用SIMD(Single Instruction Multiple Data)指令,一次处理多个数据,大幅提升计算密集型任务的性能。比如,SSE、AVX等指令集,能一次处理4个、8个甚至16个浮点数。
Emscripten也支持SIMD,编译的时候加上-s SIMD=1参数,就能生成支持SIMD的WASM代码。浏览器也都支持WebAssembly SIMD了。
我们在一些计算密集的算法里,用了SIMD优化。比如,图像的加法、减法、乘法,用SIMD一次处理多个像素,性能提升了好几倍。当然,SIMD优化比较复杂,需要对算法有深入的理解,我们只在最核心、最耗时的算法里做了SIMD优化,其他地方保持朴素实现,保证代码的可读性。
第四,用多线程做并行化。
WebAssembly支持多线程(SharedArrayBuffer + Web Workers),可以把任务拆分成多个部分,并行处理。图像处理,很适合并行化,因为每个像素的处理是独立的,可以把图像分成几块,每个线程处理一块。
Emscripten支持Pthreads,编译的时候加上-s USE_PTHREADS=1参数,就能在WASM里用多线程。我们在一些耗时的算法里,用了多线程优化,把图像分成几块,并行处理,性能又提升了几倍。
当然,多线程也有开销,线程的创建和同步,都需要时间。对于很简单的算法,多线程的开销可能超过收益,反而更慢。所以,我们只在比较耗时的算法里用了多线程,简单的算法保持单线程。
通过这些优化,核心算法层的性能,比原来的JavaScript实现,提升了十几倍。原来需要几秒的处理,现在只要几百毫秒;原来需要几十秒的处理,现在只要几秒。用户体验有了质的提升。
WASM绑定层和JavaScript服务层
核心算法层写好之后,我们开始写WASM绑定层和JavaScript服务层。
WASM绑定层,用Emscripten的EMSCRIPTEN_BINDINGS来写。它的作用,是把C++的函数和类,暴露给JavaScript调用。
绑定层的代码,很简单,大概是这样的:
#include <emscripten/bind.h>
#include "image_processor.h"
using namespace emscripten;
EMSCRIPTEN_BINDINGS(image_processor) {
function("processImage", &processImage);
function("gaussianBlur", &gaussianBlur);
function("resize", &resize);
// ... 其他函数
}这样,在JavaScript里,就可以直接调用Module.processImage、Module.gaussianBlur等函数了。
但直接调用Module里的函数,还是有点麻烦,需要处理数据的转换,比如把JavaScript的Uint8ClampedArray转换成C++的指针,处理完之后再转回来。而且,WASM的加载是异步的,需要等WASM加载完成之后才能调用。
所以,我们又写了一个JavaScript服务层,对上层提供更友好的接口。
服务层的代码,大概是这样的:
class ImageProcessor {
constructor() {
this.module = null;
this.ready = false;
}
async init() {
if (this.ready) return;
this.module = await loadWasmModule();
this.ready = true;
}
async gaussianBlur(imageData, radius, sigma) {
await this.init();
// 分配WASM内存
const inputPtr = this.module._malloc(imageData.data.length);
const outputPtr = this.module._malloc(imageData.data.length);
// 拷贝数据到WASM内存
this.module.HEAPU8.set(imageData.data, inputPtr);
// 调用C++函数
this.module._gaussianBlur(inputPtr, outputPtr, imageData.width, imageData.height, radius, sigma);
// 拷贝结果回来
const resultData = new Uint8ClampedArray(this.module.HEAPU8.subarray(outputPtr, outputPtr + imageData.data.length));
// 释放内存
this.module._free(inputPtr);
this.module._free(outputPtr);
// 返回结果
return new ImageData(resultData, imageData.width, imageData.height);
}
// ... 其他方法
}服务层做了几件事:第一,封装了WASM的异步加载,上层不需要关心WASM有没有加载好,调用方法的时候会自动等待;第二,封装了内存的分配和释放,上层不需要手动管理WASM内存;第三,封装了数据的转换,上层直接传JavaScript的ImageData,返回也是ImageData,不需要关心底层的数据格式;第四,统一了错误处理,底层出错的时候,抛出友好的错误信息。
通过服务层的封装,上层调用就很简单了:
const processor = new ImageProcessor();
const result = await processor.gaussianBlur(imageData, 5, 2.0);上层完全不需要知道底层是用WASM实现的,就像调用一个普通的JavaScript函数一样。这样,代码的耦合度就很低了。
测试体系的建设
重构的过程中,我们也建设了完善的测试体系。原来的代码,几乎没有测试,质量没有保障。重构之后,我们加了单元测试、集成测试、性能测试,确保代码的质量。
第一,单元测试。
核心算法层的每个函数,都有对应的单元测试。我们用Google Test(C++的测试框架)来写单元测试,测试每个函数在各种输入下的输出是否正确。
比如,测试高斯模糊函数,我们准备了几张测试图片,用已知正确的实现(比如OpenCV)处理一遍,得到标准答案,然后用我们的实现处理一遍,对比结果是否一致。如果不一致,说明有bug,需要修复。
单元测试,能保证每个函数的正确性。改了代码之后,跑一遍单元测试,就能知道有没有引入bug。
第二,集成测试。
除了单元测试,我们还有集成测试,测试整个流程是否正确。比如,从加载图片,到应用滤镜,到导出图片,整个流程跑一遍,看结果是否正确。
集成测试,能发现单元测试发现不了的问题,比如模块之间的接口不匹配、数据传递出错等。
第三,性能测试。
我们还写了性能测试,测试每个算法的处理速度。每次改了代码之后,跑一遍性能测试,看性能有没有下降。如果性能下降了,说明优化有问题,需要检查。
性能测试,也能帮助我们找到性能瓶颈。通过性能测试,我们知道哪个算法最耗时,然后针对性地优化。
第四,回归测试。
我们把所有的测试,都放到了CI(持续集成)里。每次提交代码,CI都会自动跑一遍所有的测试,如果有测试失败,就不允许合并。这样,就能保证代码的质量,不会引入bug。
测试体系的建设,让我们重构的时候很有安全感。改了代码,跑一遍测试,就知道有没有问题。不用担心改了一个地方,影响了其他地方。这也是我们能大胆重构的重要原因。
重构的成果和经验
经过几个月的努力,重构终于完成了。我们来看看重构的成果。
第一,性能大幅提升。
重构之后,图像处理的速度,比原来的JavaScript实现,平均提升了15倍。原来需要2到3秒的高斯模糊,现在只要100多毫秒;原来需要十几秒的双边滤波,现在只要1秒左右。用户体验有了质的提升,处理图片几乎是实时的,不需要等待。
第二,代码质量大幅提升。
重构之后,代码的结构清晰了,分层明确了,函数短小了,命名清晰了,重复代码减少了。代码的可读性和可维护性,都有了很大的提升。新同事加入,看代码就能理解整个系统的结构,不需要像以前那样,要花好几天才能理清代码。
第三,可扩展性提升了。
因为代码分层清晰,接口明确,加新功能就很方便了。比如,要加一个新的滤镜,只需要在核心算法层加一个函数,在绑定层加一个绑定,在服务层加一个方法,在UI层加一个按钮,就完成了。不需要改其他地方的代码,也不用担心影响其他功能。
第四,质量有了保障。
完善的测试体系,让代码的质量有了保障。每次改代码,都有测试来验证,不会轻易引入bug。线上的bug率,比重构之前降低了很多。
这次重构,也让我们总结了一些经验。
第一,重构不是重写,要有计划、有步骤。
很多人一提到重构,就想全部推倒重写。但全部重写,风险很大,周期很长,很容易失败。正确的做法,是有计划、有步骤地重构。先理清现有代码的结构,然后设计新的架构,再逐步迁移,一步一步来,每一步都有测试保障。
我们这次重构,就是先把核心算法用C++重写,然后加绑定层和服务层,最后改UI层,逐步迁移。每一步都能独立运行,都有测试验证,风险可控。
第二,性能优化,要先测量,再优化。
很多人优化性能,凭感觉优化,觉得哪里慢就改哪里。但这样往往效果不好,因为你觉得慢的地方,可能并不是真正的瓶颈。正确的做法,是先测量,用性能分析工具找到真正的瓶颈,然后针对性地优化。
我们这次重构,就是先用性能测试工具,测量了每个算法的耗时,找到了最耗时的几个算法,然后针对性地做了SIMD和多线程优化。这样,优化的效果就很好,花的时间也不多。
第三,代码的优雅性和性能,不是对立的。
很多人觉得,要性能就不能要优雅,要优雅就不能要性能。但其实,这两者不是对立的。优雅的代码,结构清晰,更容易找到性能瓶颈,更容易做优化。而性能优化,也不一定要写得很晦涩,很多优化,比如减少重复计算、利用缓存,都是在保持代码优雅的前提下做的。
我们这次重构,代码既优雅,性能又好。只有在最核心、最耗时的地方,才做了一些比较底层的优化(比如SIMD),其他地方,都保持了代码的清晰和优雅。
第四,测试是重构的安全网。
没有测试的重构,是盲人摸象,改了代码不知道对不对,很容易引入bug。有了测试,重构就有了安全网,改了代码,跑一遍测试,就知道有没有问题。所以,重构之前,最好先补上测试;重构的过程中,也要不断完善测试。
我们这次重构,就是先写了核心算法的测试,然后再重构。重构的过程中,不断补充测试,确保每一步都有测试验证。这也是我们能大胆重构的重要原因。
写在最后
WebAssembly的普及,给前端带来了新的可能性。很多以前在浏览器里做不了的高性能计算,现在都可以用WebAssembly来做了。
但引入WebAssembly,不只是把代码从JavaScript翻译成C++或者Rust。更重要的是,借着这个机会,重新审视代码的架构和质量,把烂代码重构成优雅代码。只有架构清晰、代码优雅、性能优秀,才能真正发挥WebAssembly的优势。
这次重构,让我们深刻体会到了代码质量的重要性。烂代码,短期看是快了,但长期看,维护成本很高,性能也上不去,最终会成为项目的负担。优雅代码,短期看是花了更多时间,但长期看,易维护、易扩展、性能好,能让项目走得更远。
当然,代码重构,不是一次就能完成的,也不是一劳永逸的。代码会不断地变化,需求会不断地增加,代码也会不断地腐化。所以,重构应该是一个持续的过程,在日常开发中,不断地优化代码,保持代码的健康。
最后用一句话来结束这篇文章:"优秀的代码,不是写出来的,是不断重构出来的。"
愿每一个开发者,都能写出优雅、高效、可维护的代码,都能享受编程的乐趣。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录