之前写过几篇在线协作相关的文章,如何实现多人协作的在线文档,在线Excel存储方案,如何实现在线Excel多人协作,在线协作如何保证消息有序、不丢、不重,今天继续和大家一起探讨在线协作系统的总体架构。我们这里说的在线协作系统包括:「在线文档」、「在线Excel」、「在线脑图」、「在线流程图」、「在线PPT」、「在线PS」等文档类的系统。我们主要分前端和服务端两部分来讨论。
前端架构
如上图,前端分展现层、引擎层、存储层,三层架构。
展现层
展现层主要有文件列表页、文档编辑页、表格编辑页等用户最终使用页面,可以选择Vue、React、AngulaJS等框架开发。
引擎层
引擎层包含文档、表格、脑图等核心插件及一些公共插件。文档插件包括文本内容监听、文本样式处理、光标处理、文档工具栏、文档OT等文档编辑器的核心逻辑。表格插件包含表格展示区域绘制、excel函数、工具栏、sheet页管理、图表管理、表格OT等功能。脑图插件包含图形库管理、位置计算模块、画板管理、左侧图形分类管理、图形库中的文字管理等模块。总之各个核心插件独立负责自己的业务功能。这些核心插件有很多公共模块,比如弹层模块(我们这里的弹框不一定显示在页面中心,也可以是类似下拉框的弹出层)、滚动条插件、颜色面板选择等等。这些公共模块我们拆成独立的插件。
引擎层拆分成独立的插件和公共模块可以提高代码的复用性,减少重复开发;同时也可以使各模块内部逻辑高内聚,模块间低耦合;带来的副作用是插件过多,不容易管理。对插件版本可以通过私仓npm管理,把每一个插件都单独发布到到仓库中,各插件在package.json中维护好版本依赖。这样虽然能解决版本依赖问题,但实际操作时还是有一些管理成本的。
对于代码仓库的管理,如果每个插件一个git仓库,会有很多仓库,不好维护。可以采用lerna多包管理,把关联性比较强的模块放在一个git仓库中。
存储层
存储层负责:维护WebSocket链接、协议降级、心跳检测、管理离线数据。我们的长链接通常采用WebSocket协议,在一些不兼容的环境下也可以降级为长轮询;长链接的维持还需要前端发送心跳检测给服务端,告诉对方自己处于存活状态;长链接异常断开时需要重连;总之要把网络协议处理逻辑收敛在存储层。
在线协作系统为了保证数据不丢失,本地存储是必不可少的一部分。本地存储可以采用IndexDB或LocalStorage,需要做好数据大小控制,防止存储内容超出浏览器限制和占用用户电脑过多存储空间。
服务端架构
服务端分为:接入层、业务逻辑层、存储层、数据库层。下面介绍下各层的职责及交互协议。
接入层
接入层有用户验证、生成session、维持WebSocket链接、维护本地WebSocket列表、请求转发、发送广播消息等功能,同时接入层作为整个系统对外统一出口,还负责部分安全工作,具体有熔断、限流、防止DDOS攻击、IP黑白名单等。接入层基本不负责具体的业务逻辑,我们把业务逻辑都放到业务逻辑层,保持接入层轻量,以保证转发的高效。
接入层和浏览器之间普通的请求采用HTTP1.1协议,文档、表格、脑图、流程图、PPT、PS等「内容编辑操作」采用WebSocket协议传输。浏览器和接入层可以采用Protobuf协议约定内容传输格式。
我们把对文档、表格、脑图、流程图、PPT、PS内容的编辑、修改、删除统称为内容编辑操作
业务逻辑层
我们根据业务把系统划分为不同的功能模块,比如人员管理、文档管理、导出模块、评论管理、模版管理、权限管理等;每个模块单独开发部署。在开发人力不足、系统压力不大的情况下也可以把部分模块合并统一管理。业务逻辑层还有几个比较特殊的模块,都是在协作场景下做操作合并的,有文档OP、电子表格OP、脑图OP、PPT OP、PS OP等。因为OP操作逻辑比较独立,占用CPU、内存往往比较高,所以拆分成单独的服务部署,方便合理分配资源,也可以避免一种文档出问题影响其它业务。
接入层和业务逻辑层之前的请求分成了两类,一类是普通的HTTP请求,一类是内容编辑操作。对于普通的HTTP请求接入层直接转发给对应的业务模块就可以了,两层服务之前是内网调度,可以采用RPC协议。内容编辑操作我们采用MQ传输,通过文档的类型区分不同的Topic,根据文档的ID选择Partition有序传输消息。为什么内容编辑操作采用MQ传输呢?一是通过MQ解耦长链接的转发和业务处理逻辑;二是内容编辑操作很容易产生大量的并发请求,通过MQ消峰填谷;三是通过MQ完成消息广播的功能,当用户的编辑操作处理完成之后,广播给其他打开文档的用户;四是通过MQ产生有序消息,方便各OP模块顺序处理用户操作。为什么没有把全部请求都通过MQ转发呢?所有请求通过MQ转发,理论上是行得通的,但是会增加开发人员的工作量,当前系统的并发量不大,调用链路也不是很长,所以没有必要。
在业务逻辑层还有一些公共模块,我们把第三方系统的调用逻辑放到单独的模块,屏蔽内部系统和外部系统之间的差异,防止因为外部系统升级带来的问题,也方便以后合作方升级接口或者更换合作方。单独的日志模块,保存日志并提供日志的trace追踪能力。
消息广播也可以选择注册中心的方案,可以参考如何实现在线Excel多人协作,我们在这里就不赘述了。
数据逻辑层
数据逻辑层的职责很简单,屏蔽数据库的直接调用,让业务逻辑层专心处理业务。为什么要屏蔽数据库的直接调用呢?比如我们本来存储文档用的MySQL数据库,后来随着数据量变多,运维能力提升,要迁移到TIDB,就可以只改造数据逻辑层,上面的模块都不受影响。再比如我们的系统请求量变高,数据库压力过大,需要增加缓存,也可以在数据逻辑层处理缓存和库的数据一致性性问题,业务逻辑层仍然不需要做任何修改。
数据库
数据库层,需要我们根据业务场景和公司的运维能力做出合适的数据库选型。对于不同类型的数据,根据其读写操作特性,设计合适的存储方案。比如对于在线文档,一个文档的内容往往不是特别大,而且读写操作总是对整个文档生效,我们完全可以把整个文档内容存储在关系型数据库的一个字段中;对于Excel我们的读操作是单个sheet页,写操作绝大多数是某几个单元格内容变化,所以可以采用文档类数据库保证高效的修改部分字段。
升级异地多活
前段时间东北、福建几个省市出现了限电情况,如果我们的服务只在一个机房出现类似的紧急情况,系统就无法对外提供服务了,所以我们需要异地多活的架构来保证服务的高可用。
我们如何把上面的架构图升级为异地多活的架构呢?如下图:
客户端通过智能DNS请求最近的接入层服务,服务的访问地址存入注册中心,根据机房信息查询本机房下游服务的地址,通过RPC调用。
MQ自身做多数据中心方案,各机房接入层通过域名访问。
DB存储可以采用直连或代理的方式访问,我对MySQL比较熟悉一些,我们就以MySQL为例说一下直连和代理的两种典型实现方式。
直连,读写分离:
如上图:所有写请求访问主库,读请求访问本机房的从库,如果一个写库压力太大时,可以按照文档ID分片。
代理模式:
数据逻辑层访问数据库中间件,数据库中间件解析SQL自动选择对应的库。除MySQL外的其他数据库也都有适合自己的多数据中心方案。
此时我们整个系统,接入层、业务逻辑层、数据逻辑层都部署在多个机房中,MQ和数据库也都在多机房,保证了异地多活。智能DNS的就近访问策略可以让用户访问距离最近的实例,各服务优先访问本机房也避免物理距离带来的网络延迟,不可避免的跨机房访问尽量减少,机房之间也需要多条专线,减少访问延迟,保障互通性。
数据可靠性
在线协作场景中最难解决也是对用户影响最大的就是如何保证用户的操作不丢失不出错。
为了解决断网、服务假死情况下用户操作丢失问题,必须要做离线存储。离线存储应该存哪些数据呢?我们不能保存单个文档的所有数据,因为文档过大会占用大量的本地存储空间,而且还需要维护本地文件内容和数据库数据的一致性,成本很高。我们应该存用户的操作记录,根据前端生成的操作版本我们保存用户最近的一些操作记录,当网络重新连接成功后,我们提交用户操作比对其他用户对文档的操作内容,合并内容。当版本差异过大无法完成合并时,给出用户提醒,让用户手动处理。
服务端如何保证数据不丢失,可以参照这篇文章在线协作如何保证消息有序、不丢、不重
总结
在线协作场景更加倾向于CP模型,服务端需要最大限度的保证用户操作保存成功。此外OP操作也是一个难点,OP量大了之后非常耗内存和CPU,再就是合并操作本身逻辑也比较复杂。
在线协作场景中,前端占的比重很大,绝不是一张架构图能描述清楚的。我们本文只是描述大的框架,其中文档、表格、脑图、PPT、PS每一个插件的实现都需要很复杂的逻辑,插件的内部也需要继续分层、封装。前端的离线存储也是必须要存在的功能,以保证断网情况下用户操作不丢失。
最后,每个公司都有自己的业务场景、用户量、开发人员情况,对于系统架构的讨论,我们应该明白设计和选型的原因,然后结合实际情况,做出正确的决策。