W3C

Web 中文兴趣组会议

2022年9月6日

题目:WebAssembly 在文档编辑器中的实践和应用

讲者:展新(阿里巴巴)[演示文稿待补充]

现场纪要

展新:

[以下内容为现场速记所得,未获得讲者正式授权。需要进一步沟通的话请直接联系讲者]

大家好!我是最后一个议题 :) 我来自阿里,在阿里十一年了,以前在支付宝,后来参与了语雀,语雀编辑器相关的开发是我参与完成的。18年到了阿里巴巴钉钉,一直在做钉钉文档,直到现在。现在主要是负责钉钉文档里的前端架构相关的工作。

今天主要和大家分享四个议题,主要分享的是WebAssembly的实践,点比较少,希望和大家交流我们在这方面的心得。今天主要从四个角度让大家比较全面的了解我们的主要功能是什么,面临的技术挑战有什么。

第二个是从比较简单的字数统计入手,讲我们遇到的问题,以及用WebAssembly解决性能上的痛点是什么。

第三个模块是钉钉文档使用过程中,使用Rust的语言解决了什么问题,最后就是点题。实际上钉钉文档在很多功能都在逐渐地试。

第一个环节是讲文档方面的需求情况。如果说现在让我回想为什么要做在线文档,我一直跟同事吐槽,已经入坑这么多年了,一直遇到的问题都比较难解决。我们要把office的功能从本地搬到在线,时间维度上是近的。经过三年的沉淀,大概总结出来钉钉文档过去那么多需求里,主要围绕三个需求去做:

第一,企业级的文档,解决在线文档和兼容性的问题,兼容性最常见的功能是分页的能力以及图文混排的能力,它对在线编辑器的挑战比较大。因为编辑器跑在浏览器里,是流式的文档,在流式的文档里如何做到分页以及和word相对齐的功能,例如图文混排。

其次是我们在线文档,例如一些工具是否符合期望,甚至一些快捷键的方式也是做编辑器最要考虑的基础功能。

我们是一个在线的文档,在线文档有一个天然的基础要求,就需要有多人实时在线编辑,因此它对在线编辑器的需求就是能不能支持远端数据忙不过来时候OT的合并以及OT的转换,甚至如何在UI上解决光标以及选区别问题。

最后是我们半年到一年内常见的需求方向,就是结合日常办公的场景,会出现一些微创新。这些微创新表面上看可能是比较简单的需求,例如说基于文档做翻译,基于输入的过程中做智能纠错。

但是,这些微小的创新直接挑战了编辑器前端架构设计上的一些积累,甚至是它对现在架构要求,是要求文档模型跑在内存里的模型会做实时计算以及分析,甚至还要反馈到前端的界面上来。

有这么多的功能需求,演变了三年多,过去三年,我们一直在往前迭代。这么多需求背后,实际上我们沉淀了很多技术项目。从最开始的文本编辑器,基于协同编辑的能力,我们打造了多人协同的协同引擎。在协同引擎的背后,刚才有分享同学介绍了历史记录的回滚,还有解决冲突的问题,甚至是这样多人协同编辑场景,也讨论了离线场景是如何解决编辑器里多人编辑的问题。

因为我们是做偏office类的文档,所以对于在线编辑兼容性的要求非常高,所以前两年我们一直在往排版引擎靠,也就是上一个分享者讲的中文排版,我自己非常受益。因为我们做网页排版引擎过程中,也踩了很多坑,如何做分页、分行等等,都遇到了UNIC排版细节里,所以我们在做排版引擎的时候做了很多优化解决排版的需求。

过去半年我们做了很多尝试,特别是解决计算引擎相关的工作。计算引擎实际上是基于文档模型,跑在内存里如何做动态计算和分析,最后反馈给用户的场景。这里最常见的三个功能点:一是字数统计;二是智能纠错,表格背后也有公式引擎,它也是对文档模型进行词法分析、语法分析,抽出一个公用的计算方式,反馈到渲染层展示出来。

这里讲演化过程主要是过去很多业务功能往前奔跑的时候,现在遇到了很多技术挑战。

比较重的技术挑战就是这么多功能叠加的情况下,我们的性能受到了非常大的挑战。性能在于文档在线编辑有几个考量指标:一是在线编辑打开一篇文档的时候,对性能的影响。

打开文档的时候,不仅受文档基线守护,还和文档性能正相关,文档越大,打开就会越慢,所以我们归尝试做一些虚拟化解决打开性能的问题。虚拟化的背后是我们要跑一个渲染引擎,解决渲染的内容,解决低频时候有什么功能。

除了打开性能之外,文档创作过程中很多编辑动作都还需要编辑性能的考量。特别是写一篇文档的时候,其实很多比较细的操作点,例如全选,它是不是能马上被选中,这也是需要考量的性能点。

除了性能之外,实际上我们还面临另一个挑战就是领域模型的一致性问题。

什么是领域模型?就是跑在浏览器或者服务器的文档结构是怎样的。为什么会有一致性问题?它有历史性原因,钉钉文档前期都是以编辑器文档为主导,很多编辑器都是用前端的同学设计出来,服务端要跑模型,我们做服务端模型的时候要通过服务端保障多人协同编辑的确定性,以及在服务端做OT操作,保证实时性。

目前我们的服务端是JAVA的语言,但这个问题会导致稳定性以及可维护性非常差。

这一part主要是讲我们的业务复杂度,目前的技术叠加功能非常多,遇到了两个问题。一个是性能问题,第二个是模型的一致性问题。

过去的一段时间里,我们通过对WebAssembly深度的了解并实践,终于找到了一部分解决方案,今天先从简单的字数统计逐渐带入实践的场景中。

我觉得大家对字数统计应该比较熟悉,它要做的事情非常简单,给我一个任务,做字数统计的需求,我会写一个word card的函数,我会取里面的test节点进行分析,最后得出有多少的字符数,甚至字符数是不是计空格,有一些特殊的需求还要计算里面有多少中文。

回到我们的文档,钉钉文档是基于浏览器,所以文档数设计上和HTML的DOM都能映射,唯一的区别是每个文档里的test副节点都有一个Block节点解决normal的问题。

[多媒体演示]这是最初的实现方式。大家可以简单理解一下整个文档的操作流,当我们在一个前端的界面输入一个字或者是删除一个字,实际上是往文档编辑器发送了一个事件,通过这个事件告诉controner发生了什么行为,它会出发model的变化。

一开始我们做字数统计是直接切入到文档里,添加一个中间件,通过中间件虽然实现比较简单,并且很快就上线了。上线了功能之后,一段时间之后,很多用户都反馈最新写文档变卡顿了,卡顿的问题追溯下来就是因为我们做这件事的时候没有考虑到字数统计背后会消耗UI的线程以及在内存消耗上占用了比较大的空间。

到了后面,整个字数统计在实现过程中遇到了一些问题,性能上会造成卡顿,除了字数统计之外,还有一些模型相关的需求,我们要统计文档里有多少行、多少区块,甚至文档里哪些部分需要上报错误日志,这些都是跑在底层的分析模块。

分析模块一多,都消费文档模型,势必会造成很多性能的开销。

所以,最后我们定义真正需要的是什么样的东西。我们需要的是一个高性能的读的文档模型,为什么强调“读”?因为我们希望写的时候让它尽量安全、稳定的写到内存模型里去,但读的时候一定要快,读的时候更多的是消费文档数的数据,这是最基础的诉求,读的时候一定要快。

第二,我们期望这样的模型能够多端运行,前面讲了一些历史问题,导致在服务端模型,可以保证前端、服务端都可以用同一套逻辑实现,这是我们希望的。

最后,希望我们可以解决类似文档分析统计相关需求上,能解决性能开销的问题。

希望是独立线程,我们希望用Rust,它可以保证高性能和多端,最后通过Web worker可以解决调度的问题。主线程跑编辑逻辑,当浏览器空闲的时候,我们再跑一些线程做字数统计,统计出来上报上去。

基于这样的实践,我们做了前后的性能对比。

当所有是用Rust统计之后,在性能上对比比较明显。我们做了10万字数统计,性能前后对比优化了3倍,字数越少是不太明显。尽管如此,我们通过Rust解决了编辑器运行过程中最痛的问题,就是模型统一的问题。

接下来第三part是讲Rust。

为什么我们要用Rust?我们调研的时候经过了分析,认为Rust比较亲和WebAssembly,因为它的语言特性好,历史包袱小。我们做钉钉文档的时候就考虑了OT算法应该保持一致,一开始没有做模型层改造的时候,算法层就直接用了Rust OT解决前端合并冲突的脚本,有了一定开发语言的经验积累。所以,我们愉快的和服务端同学一块儿达成共同维护这样的文档模型,大家一起happy。

这里我列出了钉钉文档在Rust的分层,底层是通过Rust解决文档模型数据的定义。再上层通过Java Script API维持不变,我们用JS Proxy调用WASM。

在工具使用上,基本上使用了业界的工具套装,比如我们通过Serde解决序列化、反序列化的问题。通过Wasm Pack解决构建工具,通过Bingen解决代码生成的问题,这些是工具,我们要确定什么工具使用Rust。

确定工具,第一,它是否和视觉无关,如果是UI有关、视图有关,成本比较高,一旦视图发生变化,势必要重新渲染。第二,是否和文档模型紧密相关,例如字数统计,它一定是基于文档树,例如智能纠错,也是通过文档树实时分析让用户进行操作。第三,前后端是否保持一致的交互。基于这三个逻辑确定某一个功能是否用到这个模型是否重写。

因为我们在变更过程中的问题,和大家分享,在改造过程中最大的时候是语言的差异,比如Rust和Java Script,做简单传递Java Script可以解决,当我们看到右边复杂的数据模块,在正式文档里正常的结构都是这样,可能要做对象的并列,这样的过程用Rust,它的转换方式直接影响了性能。因为大家要想想在刚才的Rust分层里,实际上除了模型层比较干净,上层的接口比较干净之外,中间还有胶水层,胶水层导致性能开发,用不同的数据类型开销不一样,复杂的数据类型开销是呈现爆发式。

我们尝试过两种返回方式,第一种返回方式是整个数都通过WASM-Bingen返回去了,我们只要通过序列化变成json数据,通过胶水层传递到前端,再通过Java Script就可以解决问题。

实际上性能的开销在于序列化和反序列化的过程,如果这篇文档有10万字或者文档的节点程度比较深,开销是呈现爆炸式。大家都知道retor,一旦改变节点会重渲,页面会导致编辑器紊乱,这样就会写个字就闪一下,这是很难接受的。结合Rust本身,我们用Rust编写文档模型,都定义一个node类型,node型返回到前端有一个具体的位置,这样导致我们在文档树操作上都会非常小心的改造它,特别是对一些克隆的操作,都需要对指针判断做一定的处理,导致它不会出现空指针的问题以及内存泄漏的问题。

但是,这样的过程中有很大的性能提升,因为我们直接消费的是内存里指针位置的调整,不是直接拷贝对象。基于这样的变化,也做了一些统计。

从一个50个节点的数,就能得出不能使用整个对象的拷贝,而是使用指针的拷贝。这个数据就是我们做了一个测试,在同一个环境下,测试一颗内容为空但有50个节点的树,性能相差了10倍,下面是返回指针的方式。这是非常恐怖的,因为随便一篇文章就存在50个以上的节点,这样文档节点一多,实际上性能开销就在这里,通过这样的指针方式解决,避免胶水层序列化和反序列化过程中的数据开销。

上面基本点到了钉钉文档使用Rust过程中比较精巧的点。

最后向大家介绍最近我们上线的一个功能智能纠错,智能纠错也涉及到对文档的一些修改。它和字数统计有点区别,大家可以看一下这是智能纠错的数据操作流程图。[多媒体演示]

它最大的区别在于用户输入的字符之后,背后需要提取出有哪些文字是出错的,提取的过程并不一定由我们这个文档的服务来支撑,我们会有达摩院的智能纠错服务,来消费当时的文档模型数据,再给我们一个反馈,说这个过程中地方哪个地方出错了,树或者是文档打上标记,返过来把文档模型渲染出来。

为什么这里要讲这样的智能纠错,因为我们在这一块正式比较深度的验证了在Rust的实践,它的差一点在于我们通过一个标记以及校验,解决了对文档模型的处理。

最后这一章,刚才我讲的部分已经在这里体现了,通过worker解决数据修改,通过另一个线程解决分析模块的问题。

最后通过服务端保证数据正确的广播到每个正在协同编辑人的端上。

最后做个简单的小结,钉钉文档跑了三四年了,实际上我们在新技术上一直比较往前,在一些比较深的技术细节没有向大家展示,包括我们做表的计算背后也是用Rust模型跑性能,分线程跑它的计算公式。所以,这是我们在计算引擎上的一些分享。

我觉得,这些新技术的使用肯定能够帮助我们在业务上解决一些突破点,也愿意和大家更多交流分享。

今天时间比较仓促,准备比较少,大概就到这里,谢谢大家!


返回[会议总结页面]获取其他话题的会议纪要。

若您对上述内容有任何疑问或需进一步协助,请联系:会议主办方 W3C 北航总部 <team-beihang-events@w3.org>。