大家好,我是前端西瓜哥。
之前时不时聊过一些图形编辑器的数据后端持久化的问题,今天我们来稍微展开,专门地聊一聊。
本地到远端的演进
首先我们有个本地图形编辑器,数据保存在本地。
单机形式
这是单机的形态,通常我们会将当前图形编辑器编辑的图纸保存到一个文件下。当用户在图形编辑器上进行编辑,会把最新的数据写回到文件上。
如果数据量太大,写入性能不高,也可以做 增量 patch 写入,之后在合适时间再应用 patch 还原回正常状态,降低体积大小。
单机模式的优点是,可以限制用户不能同时用多个本地应用打开相同文件,就不会出现多个编辑器都在操作一个文件的出现冲突的问题情况。即使有,提出文件正在被其他应用编辑,不允许操作即可。
Web 形式
现在我们要改为 Web 端的方式放到浏览器,问题就变得复杂了。
一个问题是,数据应该保存在哪里?
如果要继续做成单机模式,就要将数据保存在本地文件上,就涉及到浏览器的权限问题,浏览器会时不时要你授权权限,虽然是可以用,但体验很糟糕。保存到浏览器上,则有浏览器清除缓存,丢失数据的风险。
单机模式的做法是别扭的,更好更常见的的做法是保存到后端。
好处是可以实现 多端同步,且从单机买断模式转为 订阅模式,有利于工具长期盈利。
缺点是实现复杂度上升了,以及后端对数据安全和容灾要求很高,一次严重数据丢失会导致用户放弃你的产品,对开发团队的技术能力有很大的挑战。
同步方案
首先我们确定一下本地数据同步到后端的方案。
全量同步
最简单的做法是,每间隔较短时间将本地的数据全量提交到后端。
优点是实现简单。
缺点就比较多了,首先数据量太大的时候(比如达到几百 MB 时,这个不算少见),会像上传一个较大的文件,不仅考验用户的网速,而且后端的带宽压力也非常大,只要十几个用户同时上传,后端就原地爆炸了。
冲突问题也不太好解决,会出现两个客户端的提交相互覆盖的问题,晚提交的会覆盖早提交的。
所以这个方案场景比较有限,只适合一些数据规模比较低的场景,比如图形数量固定,不允许创建超过特定数量(较少)的图形,且用户不太在意提交互相覆盖的问题。
增量同步
全量提交非常耗宽带,一个容易想到的解决方案,是 只提交增量的修改。
比如更新了一个图形的颜色,其实我们只要提交这个图形的 id 和新的颜色值就可以了。
数据量过大的问题算是解决了,下面我们再来看看多人协同编辑的问题(一个用户同时在两个客户端打开同一张图纸编辑也算多人协同)。
看一个场景,假设客户端 A 和客户端 B 同时修改同一个图形的颜色,那这个图形最终应该是什么颜色?如何确定先后顺序?
多人协同有个重要的指标,就是 “数据最终一致性”,保证不同的提交时序能得到最后的结果是一致的。我们想到的一个简单的方式:根据提交到后端的时间,晚的覆盖早的,作为最终结果。
但这样却会可能导致导致明明现实中更早的修改却覆盖掉晚的修改的,比如网速不好的情况。你可能会说,网速差一点问题也不大,一两秒的误差可以接受。
这时候就要说到多人协同的另一个指标 “离线编辑”。即你断网了,你依旧可以进行编辑,等网络正常后,再将你的修改一次性都提交上去,这时候误差就不是一两秒了。你不会希望你两天前的修改覆盖掉你的最近修改。
对于时序问题,我们需要用 逻辑时钟 来解决,这里不展开说,感兴趣看 这篇文章。
再来看看多客户端之间的数据同步问题,客户端 A 提交了修改 a,客户端 B 需要接收这个修改进行应用。
客户端 B 可能离线了一段时间,这时候它可能不仅要接受修改 a,还要接受离线那一段时间的其他修改,服务端可以把这些批量修改做一个压缩。
这些数据的处理异常复杂,所以我们通常需要一个专门的解决方案:“协同算法”。
CRDT 和 OT
处理多人协同的数据问题,目前主流的算法有 CRDT 和 OT 算法。
具体对这这两个算法的介绍我就不说了,网上有大量的资料。
CRDT 会更通用一些,但缺少语义,代表的库是 y.js。OT 有语义,但实现复杂,代表的库是 ShareDB。
为了降低实现的难度,我们一般会用上这些协同库。
但并不是说接上这些库的 API 就完事了,那只是简单的场景问题不大了,我们还要针对图形编辑器的功能做一些更精细化的处理。
历史记录
首先是历史记录的问题。
协同库可能会提供历史记录功能,比如 y.js 就支持开启撤销重做功能。
const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext)
ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
简单的数据上的撤销重做问题不大。
但我们可能还有其他的非数据类型的撤销重做,比如选中图形、切换画布的之类的 非数据操作 也要保存到历史记录中,这种场景下就不够用了。
另外,一些 操作可能也带有副作用,比如撤销需要进入某个图形的编辑状态(典型的是钢笔工具),此时如果重做则要退出这个状态。
协同库定义的是比较底层的操作,一些有关联性的带语义操作,协同库无法很好地处理冲突。
比如嵌套组的场景下,调整子节点,会导致父节点的尺寸的变化。我们可以会将子节点和父节点的变化都计算好,得到 deltaChild、deltaParent,进行提交。
但如果另一个客户端也在修改另一个子节点,计算的父节点的尺寸是另一个值,最终父节点将会是这两者中的一个。
但其实它们都不对,正确的应该是两个子节点都参与运算,得到的父节点尺寸。
这其实属于和特定图形编辑器的功能绑定的 更高维度的一种特殊原子操作,协同库的处理可能会不符合预期。
最后有一个比较重要的原则是:确保历史记录撤销然后重做后,能够达到最新的状态。所谓最新状态,就是一些其他客户端同步产生的改变合并到历史记录上,确保数据的最终一致性。
离线编辑
离线编辑这个协同库可以帮我们做。
我们往协同库的文档对象写入内容,如果此时连网,变更的数据会先节流通过 websocket 提交给后端。
如果离线,则会将变更的 patch 数据暂时保存在浏览器本地,等待连网后再提交给后端。此时关闭图形编辑器,之后再打开会自动提交。
天生无锁
因为使用了 CRDT 和 OT 算法,所以不建议在操作上加锁。
在功能的实现上就要考虑到一些冲突的场景,任何交互都要考虑被打断的情况。
比如客户端 A 在绘制一个矩形过程中,客户端 B 把它删除了,客户端 A 的绘制过程就会出现矩形丢失的场景,这时候可以结束绘制,或进行一个无实体表演(客户端 B 也可能会撤回让这个矩形又回来了)。
但有一些场景因为编辑的数据结构过于复杂,我们希望提高编辑的粒度,避免多个客户端的同时编辑。
典型的场景是编辑图形编辑器中编辑路径和文本,它们都很复杂,我们不希望在它内部提供更细粒度的协同编辑,复杂度太高,也没有太大必要。
所以它们的数据的粒度都会很大,会直接以整个内容去做 last-write-win 操作。
锁虽然不能加,但我们可以让其他的用户退出同一对象的编辑状态。
比如多个客户端同时编辑文本,当一个客户端对内容进行了编辑,其他客户端会得到通知,然后自动退出文本编辑状态,避免大粒度的大范围相互覆盖问题。
中间状态
在设计上,中间状态要尽量少。
比如文本编辑的过程中,每次的键盘输入操作都应该产生历史记录,而不是等回车或失焦后才产生。
一是中间态可能会丢失数据,如果文本编辑状态下客户端意外关闭,或是承载的对象被其他客户端删除,就会丢失中间状态数据。
二是为了 尽可能实时通知其他客户端内容的变更,减少冲突的产生。
大资源问题
对于图纸中可能会有的大资源(比如大图片、大视频),我们 不能将它们保存在图纸中,而是通过引外链的方式,链接到外部资源。
不建议转成 base64 保存到图纸数据中,这会导致大文件。大文件的问题我前面已经聊过,会对服务器造成带宽压力,是不适合数据保存到后端场景的,只适用于单机场景。
具体操作就是,在图纸中添加图片时,先绘制出一个矩形,然后 url 设置为图片的唯一 id(通常为哈希值,比如 md5),提交这个对象,同步到其他客户端,其他客户端会渲染一个没有内容的图片对象。
然后再将这个图片上传到服务器,上传成功后,再让其他客户端根据拉取渲染出图片内容。
这种保存大资源 id 的方式还有一个好处,就是复制大资源的时候,不需要真的复制资源内容本体,只需要复制资源的唯一 id 即可,数据体积会很小。
服务端渲染缩略图
还有个比较常见的需求:服务端渲染缩略图。
这就需要渲染引擎需要同时支持前端和后端渲染。
方案大致有:
-
C++ 写 openGL,前端用 wasm,后端用 C++ 写的 SDK 渲染,性能最优;
-
前端用 Canvas 2D,后端用 node-canvas;
-
前端什么都可以,后端用无头浏览器 puppeteer,缺点是太重,不适合大批量生成;
-
前端什么都可以,后端转成 SVG,再转成图片。SVG 转位图有很多工具。
代码参考
我的开源图形 suika 项目里除了有编辑器的代码,也有后端和工作台的代码,
项目使用 yjs 实现多人协同,大多数场景表现上尚可,感兴趣的读者可以看看。
suika 图形编辑器 github 地址:
线上体验:
一些要点(有些没实现):
-
创建图纸应该在服务端进行,创建成初始数据后返回图纸 id 给前端,然后前端打开页面。不要前端创建然后提交给后端,可能会有脏数据,且流程冗长。根据模板创建图纸也是一样;
-
初始化,拉取完数据前,应该让图形编辑器进入不可操作的 loading 状态;
-
只读模式,需要禁用大部分功能,保留少量的功能,比如抓手工具、缩放功能,选中图形功能;
-
注意实现 awaness 的感知同步,但注意数据提交的节流,并实现光标的离散位置更新 transition 平滑动画,减少移动的滞涩感。
结尾
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,
CRDT 协同编辑:修改树的节点层级 Mutable Tree Hierarchy