最近一段时间,疫情爆发,对于全球工作人员复工提出了新的挑战!远程办公,远程授课成为刚需。 对于远程办公,肯定离不开一个词:协同。对于我们日常工作最常见的工作输出载体:文档,也离不开协同

如下图,为个人实现的协同编辑服务 - Dozo 的示意图: dozo.gif

相比于市面上成熟的商用协同产品:如 yuque、feishu、腾讯文档、石墨文档...,只能是小巫见大巫,但是对于个人使用,团队内使用还是值得一试的,后续将会开源,敬请期待。

下面将对 Dozo 的功能、架构设计和实现展开说明。

Dozo 功能介绍

协同编辑

如上图,Dozo 能够协同编辑,会实时同步其他用户的编辑光标

评论

如下图,Dozo 文档支持行评论 image.png

分享

如下图,Dozo 文档支持对外分享,发送分享链接至被分享人 image.png

可以设置密码,设置外部访问的权限:可读或可写

权限控制

除了分享文档设置读写权限以外,还可以设置文档权限,可以设置为 完全公开、组织内可见、私有,如下 image.png

同时,组织的拥有者和管理员,能够管理组织成员 image.png

内容搜索

Dozo 基于 Elasticsearch 实现了自己权限内的内容搜索 image.png

文稿模板

可以设置文稿类型为模板,从而可以从新文稿中选择模板 image.png

image.png

消息通知

当有人对你的文稿评论,或者文档中 @ 你,你将会收到邮件通知

image.png

Dozo 架构设计 & 实现

Dozo 前端

  • React
  • Antd
  • Mobx
  • Socket.IO Client
  • Slate.js

Dozo 后端

  • Express
  • Socket.IO
  • Redis
  • Elasticsearch
  • MySQL

协同编辑的实现

前端使用 slate.js 实现富文本编辑器,其中 slate.js 能够将每一次的操作抽象成自己的数据模型 Operation,于是我们只需要每一次操作,传递该操作的 operation 数据至服务端,通过服务端广播该 operation 至其他客户端即可。这样就能达到不同客户端之间的操作同步,同时我们还需要把文稿进行持久化存储。

在第一个连接建立后,服务端从数据库中读取文稿数据至内存中,并输出至客户端;当服务端获取来自客户端的 operation 之后,更新于服务端内存中的文稿对象;并且会节流的写入数据库,防止服务崩溃导致数据丢失;同时在某一篇文稿的客户端连接全部断开后,会把服务端文稿写入数据库;这样能有效的减少数据库的写操作。

分布式 & 集群

Dozo 服务的架构如下,由 8 个分布式子服务组成,各个节点又可以构成各自的集群,如下图 未命名绘图.png

其中:

  • web: HTTP Web Service,负责主要的业务逻辑
  • fileman: 负责文件处理上传、存储
  • static: 前端静态服务
  • ws: Websocket service,协同实现的服务端关键
  • worker: 负责处理其他任务,如发送通知,邮件;推送文档数据至 elasticsearch
  • 其他 redis / elasticsearch / mysql 服务就不多介绍了,第三方十分主流的服务

多节点集群的搭建

使用多节点集群和负载均衡,可以大幅度减少单节点的并发数;其中 web / fileman / static 服务多节点集群的搭建比较简单,直接搭配负载均衡,分流到不同节点即可;但是对于 websocket 服务来说,多节点服务并不简单!

其中有以下问题:

  1. 对于 socket.io 来说,建立连接之前,会一段(多个)HTTP请求响应的通信,这时候需要始终保存与固定的 ws 服务通信,所以不能通过简单的轮询来负载均衡,需要通过 ip 网段映射表来分流(会话保持),可就是同一个 ip 始终走同一个 ws 服务;

  2. 多个不同 ws 服务之间需要共享数据,共享 socket 通道;
    如,ws1 服务有 a b 客户端连接,ws2 服务有 c d 客户端连接;这时候 ws1 如何才能知道整体 ws 服务当前有多少客户端连接? ws1 可以通过 redis 发布 getSockets 消息,ws2 在订阅 getSockets 消息后,返回 ws2 所有的 sockets,从而 ws1 通过 ws2.sockets 和 ws1.sockets 合并,即可获取整体 ws 服务的 sockets;
    又如,ws1 服务需要对整体 ws 客户端广播发送 hello 消息,除了需要对自身 ws1 进程中的 sockets 进行广播;还需要广播消息 {type: 'broadcase', value: 'hello'}, ws2 / ws3 /... 接受消息后,也在当前进程广播消息 hello

  3. 在上文提到的协同编辑实现原理的简单介绍中,我们知道 ws 服务进程在内存中是有文稿数据的;这样设计在单节点的时候就没有问题的,但是多节点情境下,该方案是不可行的,随时可能会有数据不同步,数据覆盖的问题出现;
    鉴于以上问题,所以考虑把文稿数据存入单节点 redis 中,通过对单节点 redis 内存的读写操作,避免数据覆盖问题;

    但是这样问题就解决了吗?如某时刻有客户端操作发送至 ws1 节点,ws1 服务端正常的流程如下:

    async applyOperation(operation) {
       // 从 redis 获取文稿
       const doc = await redis.getDocument()  // 1
       // 更新 doc
       applyOperationToDoc(doc, operation)  // 2
       // 写入 doc
       await redis.setDocument(doc) // 3
    }
    

    当 ws1 节点完成 1 之后,进入 2 之前,这时 ws2 也收到 operation,同时 ws2 也完成 1;这样后续 ws1 / ws2 接着后续流程,可以看到,很可能会丢失掉某批次客户端的操作,至于该问题如何解决,先卖个关子,后续持续完善。