-
Notifications
You must be signed in to change notification settings - Fork 1
Document
- 在线聊天(支持私聊、群聊、发送图片)
- 工作台(审批、汇报)
- 云文档(协同编辑)

约定:两个人的群组就是单对单聊天(私聊),三人及以上就是群组聊天了。
成为好友之前需要发送邀请(根据 email 发送),若对方同意,则建立起一个两人群组,也就是私聊。
若用户 A 想要建立群聊,则 A 还需要另外两个用户,这两个用户也得是好友,也就是说建立群聊时只能邀请已经是好友的用户。
chatroom
表中有id
、userIds
、name
这三个属性,可以理解成群组 id、在这个群组的用户 id 和 这个群组的名称(若是两人的话,name 默认为空字符串)。
chat_line
表中拥有发送时间、chatroom 的 id,发送此信息的用户的 id 与 姓名、发送的信息、发送信息的类型。
主要用途就是加好友发送消息以及接收邀请信息等。使用 websocket
来实现。
- 对于加好友:A 填写 B 的 email,并发送请求给服务端,服务端首先是将这个邀请放在数据库(message Table)中,并通知 B。B 可以同意或拒绝;若 B 同意,则两人建立一个 chat room 并且更改 firent table 中的信息。若不同意,则啥也不做(这条记录只显示在B,B可以删除此条记录)
- 创建群组时过程:A创建一个群组,并邀请的有 2 人以上的人(这些人只能是自己好友),并发送请求给服务端,服务端接收要通知的人的信息后直接创建一个chat room。
- 邀请别人加入现有的群组:群组中的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,0
、B0,1
、C0,2
。
这种就效率低下,所以将长度定义成第三个坐标,它描述操作中包含的子操作的数量,则就是这样:
ABC0,0,3
不过这种设计的话,寻找到单个字符就会变得更加复杂。
比如当另一个用户希望在“B”和“C”之间插入一个字符时(在上面ABC0,0,3
的基础上),操作“BC”部分就需要分成两个独立的部分。
并且在 CRDT 中永远不能删除字符或者从文档树中删除字符。
所以表示删除这个操作,就是将某个字符标记成删除。就比如(段落)D
注意:在 Yjs 中,操作的对象包括了id(操作的标识符)
、left(左边对象的标识)
、right(右边对象的标识)
、delete(表示删除的状态(是否已删除))
、content(表示插入操作中要插入的内容)
以上就是 CRDT 的大致算法。
在上面的演示中,可以看到这样一个界面:

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