UMind 是一款在线脑图产品,由 脑图编辑 和 多人协作 两部份主要功能所组成。自 2018 年 09 月立项至今经历大大小小 24 个版本打磨终于迎来 1.0 正式版本。
架构大图
从架构大图中我们可以看到 UMind 依赖于 GGEditor,是一个前端全栈产品。其中 Node 部分主要用于多人协同操作时的数据同步,后面章节中会详细讲解。最上一层的产品端则可通过 UMind 提供的 PASS 服务能力独立部署脑图产品。如果你想要马上尝鲜多人协同功能,现在可以登录 Cloud Mind 平台进行体验,年后 Team File 也将接入 UMind,届时 UMind 将会服务于集团所有的小伙伴:)
可视化图编辑器
说起 UMind 就必须提到 GGEditor,曾经有很多小伙伴质疑过 GGEditor 的项目名,其实 GGEditor 的全称是 Great Graphic Editor,是不是突然感觉上高大上了不少 :)
流程图 | 脑图 | 拓扑图 |
![]() |
![]() |
![]() |
GGEditor 基于 Antv G6 与 React,提供流程图、脑图、拓扑图的可视化编辑能力。集团内部我们专注于技术赋能,起初用于 Schema 编辑器、AIBoost 流程化搭建项目,目前已经超过 25+ 项目接入,集团外部我们在 GitHub 上的 Star 数已超过 650+,并有幸入选开源中国 2018 年度国产新秀榜。
多人协作版脑图
可能大家之前会感觉多人协作的实现比较复杂,但是真正实现起来无非需要解决以下两个问题:
- 操作数据的实时同步
- 操作数据的冲突处理
操作转换
首先我们需要将用户的操作行为转换为对应的操作模型以方便后继的操作数据同步与冲突处理,格式如下:
{
"command": {
"name": "INSERT / DELETE / UPDATE / MOVE",
"data": {
"id": "",
"model": {...}
}
}
}
name
字段表示操作行为,分别对应插入、删除、更新、移动四种用户操作行为。
data
字段包含操作数据,其中 id
用来标识节点,model
则是节点数据模型。
数据同步
这里讲的数据同步分为两层:
- 第一层是浏览器与 Node 服务器之间的双向通信
- 第二层是存在多台 Node 服务器之间的数据同步
第一层可以通过 WebSocket 协议实现双向通信,我们使用的是 Socket.IO 类库。第二层则是能过 MetaQ(对应 MQ 产品)来保障分布式多服务器之间的消息广播。
冲突处理
相较于富文本编辑器的多人协作,得利于脑图每个节点的唯一标识,使得冲突的处理能够更加的简单。
实现思路
上图所示便是最常见的冲突例子:A 用户与 B 用户对同一节点同时执行更新与删除操作,C 用户则需要同步操作。
为了达到所有用户最终结果统一,每当同步操作数据后需要根据被操作节点的状态标识来决定是否同步或舍弃操作:
- A 用户需要同步删除操作
- B 用户需要舍弃更新操作
- C 用户如果先同步删除操作(广播消息无法保证顺利)则也需舍弃后续的更新操作。
那么应该如何标识各节点状态呢?还记得在这之前我们已经定义过了用户的基本操作行为,再配合用户执行操作时的时间戳便能够标识各节点的状态。这里以 B 用户为例,执行删除操作之后的节点状态如下:
{
"NODE01": {
"INSERT": 100000000001,
"DELETE": 100000000003
}
}
随后 B 用户接收到更新操作广播:
{
"command": {
"name": "UPDATE",
"data": {
"id": "NODE01",
}
},
"t": 100000000002
}
经过对比 DELETE
与 UPDATE
的操作时间,最终决定舍弃本次更新操作,至此冲突解决。
实现细节
上个章节我们大体介绍了冲突处理的实现思路,但是实际开发过程中遇到的问题往往更加复杂,这个章节我们就来聊聊冲突处理中的实现细节。
分解移动操作
节点的移动操作可以看作是删除操作与插入操作的组合,最终只需记录下插入操作的时间戳以备冲突处理时使用。
删除父级节点
当删除一个父级节点时不仅需要标识该节点的删除状态,也需同时标识其后继子节点为删除以备冲突处理时使用。
更新多级属性
更新节点较于插入与删除更为复杂,因为会涉及到节点预设的主题样式,所以需考虑多层属性合并冲突处理。
如上图所示:
- A 用户选择了预设样式(文字颜色:红色,文字大小:14,节点背景:白色)
- B 用户更新了节点样式(文字颜色:蓝色)
然后经过冲突处理,希望得到的结果是:
- A 用户在原有的预设样式基础上合并 B 用户的更新操作。
- B 用户则需要同步到除文字颜色之外 A 用户的更新操作。
为了能够实现以上这种冲突处理的效果,我们需要分别记录各个属性的操作时间,收到更新广播之后再依次对比进行取舍。这里我们以 B 用户为例来讲解具体实现:
B 用户更新字体颜色之后的结点状态:
const NODE_STATUS = {
NODE01: {
UPDATE: {
'textStyle.fill': 100000000002
}
}
};
紧接着收到了 A 用户发出的更新广播:
const message = {
command: {
name: "UPDATE",
data: {
id: "NODE01",
model: {
textStyle: {
fill: "red",
fontSize: 14
},
rectStyle: {
fill: "white"
}
},
paths: [
"textStyle.fill",
"textStyle.fontSize",
"rectStyle.fill"
]
}
},
t: 100000000001
};
开始依次对比获取真正需更新的属性:
使用
.
符号分割属性层级的好处:能够在pick
方法中直接当作参数使用
const { command, t } = message;
const { id, model, paths } = command.data;
const updatePaths = [];
paths.forEach((path) => {
if (NODE_STATUS[id]['UPDATE'][path] > t) {
return;
}
updatePaths.push(path);
});
console.log(_.pick(model, updatePaths));
写在最后
UMind 1.0 版本发布可以视为从 0 到 1 的过程,这里特别感谢 Antv 团队 @萧庆 @有田 的支持。接下来无论在脑图功能的打磨还是多人协同的探索方面我们都还有很多的路要走,如果正巧看到这篇文章的你有任何意见或是兴趣都欢迎通过以下方式来联系我们。
简历投递:fangqin.fq@alibaba-inc.com
业务接入: