Skip to content

Document

Zeekg edited this page Aug 1, 2022 · 6 revisions

目前已有大致功能

  • 在线聊天(支持私聊、群聊、发送图片)
  • 工作台(审批、汇报)
  • 云文档(协同编辑)

具体实现与思路

聊天好友及群组聊天的表设计

聊天表结构

约定:两个人的群组就是单对单聊天(私聊),三人及以上就是群组聊天了。

成为好友之前需要发送邀请(根据 email 发送),若对方同意,则建立起一个两人群组,也就是私聊。

若用户 A 想要建立群聊,则 A 还需要另外两个用户,这两个用户也得是好友,也就是说建立群聊时只能邀请已经是好友的用户。

chatroom表中有iduserIdsname这三个属性,可以理解成群组 id、在这个群组的用户 id 和 这个群组的名称(若是两人的话,name 默认为空字符串)。

chat_line表中拥有发送时间、chatroom 的 id,发送此信息的用户的 id 与 姓名、发送的信息、发送信息的类型。

实时消息推送

主要用途就是加好友发送消息以及接收邀请信息等。使用 websocket 来实现。

  1. 对于加好友:A 填写 B 的 email,并发送请求给服务端,服务端首先是将这个邀请放在数据库(message Table)中,并通知 B。B 可以同意或拒绝;若 B 同意,则两人建立一个 chat room 并且更改 firent table 中的信息。若不同意,则啥也不做(这条记录只显示在B,B可以删除此条记录)
  2. 创建群组时过程:A创建一个群组,并邀请的有 2 人以上的人(这些人只能是自己好友),并发送请求给服务端,服务端接收要通知的人的信息后直接创建一个chat room。
  3. 邀请别人加入现有的群组:群组中的A邀请好友B,会发送请求给服务端,服务端将信息存数据库,并通知用户B,B可以同意或拒绝,同意的话直接在chat room加入一条记录。拒绝的话啥也不做。
// message table 设计
model Message {
  id         String @id @default(uuid())
  message    String // 此条消息的内容
  state      String @default("pending") // pending | agree | reject
  sendUserId String
  recvUserId String
}

协同文档的创建

直接创建的文档是不会开启协同功能的,因为当前可以编辑的用户只有自己一人。

可以看clouddocument表的结构

云文档表结构

clouddocument表中有个名叫 collaborators 的属性,它用来表示此文档是否是协同文档。

当初次创建文档时,collaborators 默认为空字符串,表示没有协同者,也就是普通的文档。

当邀请用户时(只能是好友),会更改 collaborators 的值(也就是将新加入的用户 id 存入其中)。

编辑协同文档

具体操作过程如下:

协同编辑

采用了 Yjs 中的 y-webrtc 来实现此功能。

webRTC 中有个 room 的概念,进入同一个 room 的用户就可以进行编辑。

为防止重复多次创建 room,也为了退出之后可以重新进入之前的房间。这里采用了将文档的 id 作为了 room id,并且将提供 webRTC 的 Provider 设计成了单例模式

至于如何准确地知道其他用户对某个地方进行编辑,这就需要 Yjs 中的 Y.DOc 提供的功能。

因为 Yjs 内部采用的 CRDT 算法来实现协同,所以这里稍微介绍下 Yjs 中的 CRDT 算法。

先说明与之对应的 OT 算法的大致思路:

OT 算法的核心思想是操作转换,也就是为了不同用户编辑的文档达到一致的状态,我们需要对一方的操作 a 和另一方的操作 b 进行操作转换 transfrom(a, b) => (a', b') 得到两个衍生的操作作 a' 和 b',再将这两个衍生的操作给对方执行来达到一致的状态。

这里所说的操作都是以绝对位置来进行的(可以简单理解成下标),比如 在 index=2 插入 XXX 这种意思。不过这种方式就非常容易引起脏数据并发冲突问题。

OT 的解决方案就是当编辑器收到协同操作时,就需要对之前的操作进行转换,最终执行的就是转换后的操作。

与 OT 算法有些不同的 CRDT 算法,它就不是以绝对位置来判断了,它是以唯一标识符来判断,这就避免了脏数据的发生。

所以在Yjs中,所有单个字符都被赋予了唯一标识符

比如C0,1,这就表示用户0执行了时钟 1 的操作 (官方说唯一标识符在内部被称为时间戳,但只是时间位置意义上的,并不是指的Unix时间戳或类似的东西)

不过这句话,说对也不对。

为每个字符都赋予一个唯一的标识符,这无疑会成为一个性能噩梦。所以在 Yjs 中也做了很多优化。

比如输入字符串“ABC”,也就是表示成A0,0B0,1C0,2

这种就效率低下,所以将长度定义成第三个坐标,它描述操作中包含的子操作的数量,则就是这样:

ABC0,0,3

不过这种设计的话,寻找到单个字符就会变得更加复杂。

比如当另一个用户希望在“B”和“C”之间插入一个字符时(在上面ABC0,0,3的基础上),操作“BC”部分就需要分成两个独立的部分。

并且在 CRDT 中永远不能删除字符或者从文档树中删除字符

所以表示删除这个操作,就是将某个字符标记成删除。就比如(段落)D

注意:在 Yjs 中,操作的对象包括了id(操作的标识符)left(左边对象的标识)right(右边对象的标识)delete(表示删除的状态(是否已删除))content(表示插入操作中要插入的内容)

以上就是 CRDT 的大致算法。

协同文档的冲突

在上面的演示中,可以看到这样一个界面:

文档冲突

clouddocument 表中有个叫 version 的字段,它用来表示文档的版本。

当协同中的某用户进行保存之后,会更新 version 字段,当其他用户在进行保存时,会判断当前版本与数据库中保存的最新版本值是否相等,若相等则直接保存,若不相等就会提示上面这个图。