Skip to content

协同实现原理 ​

协同技术 ​

  1. 底层使用的协同库是 yjs,官网:https://yjs.dev/,文档:https://docs.yjs.dev/

  2. 使用的协同数据传输类型为 y-websocket,可自定义协同服务处理逻辑

  3. 深度解析 Yjs 协同编辑原理【看这篇就够了】

官方服务源码解析 ​

Step1 创建服务实例

关键就是创建 ws server 实例,然后监听客户端连接事件,当有客户端连接时,调用 setupWSConnection 进行处理。

js
const WebSocket = require('ws')
const wss = new WebSocket.Server({ noServer: true })
const setupWSConnection = require('./utils.cjs').setupWSConnection

// 关键是监听连接的事件,触发 setupWSConnection 方法
wss.on('connection', setupWSConnection)
Step2 客户端连接处理

当有客户端连接时,调用 setupWSConnection 进行处理,并对参数进行处理

js
exports.setupWSConnection = (conn, req,) => {
   // listen and reply to events   
   /** @param {ArrayBuffer} message */ 
   conn.on('message', message => messageListener(conn, doc, new Uint8Array(message)))
}
Step3 消息处理

messageType 0: 消息数据类型,1: 用户意识数据类型(用户名、光标、颜色等)

然后执行异步读取数据

判断是否需要广播发送给其他客户端

js
const messageListener = (conn, doc, message) => {
  try {
    switch (messageType) {
      case messageSync:
        /** 关注一下这个方法 */
        syncProtocol.readSyncMessage(decoder, encoder, doc, conn)
       
        if (encoding.length(encoder) > 1) {
          send(doc, conn, encoding.toUint8Array(encoder))
        }
        break
    }
  } catch (err) { }
}
Step4 读取数据

根据数据类型,判断采用什么方式进行文档的合并操作

js
export const readSyncMessage = (decoder, encoder, doc, transactionOrigin) => {
  const messageType = decoding.readVarUint(decoder)
  switch (messageType) {
    case messageYjsSyncStep1:
      readSyncStep1(decoder, encoder, doc)
      break
    case messageYjsSyncStep2:
      readSyncStep2(decoder, doc, transactionOrigin)
      break
    case messageYjsUpdate:
      readUpdate(decoder, doc, transactionOrigin)
      break
    default:
      throw new Error('Unknown message type')
  }
  return messageType
}
Step5 执行合并

对 ydoc 进行更新操作

js
/** readSyncStep1 最终调用的函数 */
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
  if (updates.length > 1) {
    if (encoder.constructor === UpdateEncoderV1) {
      return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update)))
    } else if (encoder.constructor === UpdateEncoderV2) {
      return mergeUpdatesV2(updates)
    }
  }
  return updates[0]
}
js
/**readSyncStep2 、readUpdate 最终调用的函数 */
export const readSyncStep2 = (decoder, doc, transactionOrigin) => {
  try {
    // read update from remote 直到这里,都知道是如何实现协同的了吧
    Y.applyUpdate(doc, decoding.readVarUint8Array(decoder), transactionOrigin)
  } catch (error) {
    // This catches errors that are thrown by event handlers
    console.error('Caught error while handling a Yjs update', error)
  }
}

温馨提示:

上诉方法是官方的 y-websocket 的实现哈,我们可以直接使用,当然也可以自己实现。官方提供的服务对于个性化功能的拓展,是无法实现的,因此,自己实现的服务,也要会哦!

启动官方服务:

bash
npm install y-websocket

cd node_modules/y-websocket/bin

node ./server.cjs

自定义 y-websocket 服务 ​

上诉我们利用的是官方提供的服务端的能力进行协同处理,那么,客户端就没有能力处理嘛?我们看看客户端的代码:

Step1 创建客户端连接

provider 是一个 y-websocket 的实例,它负责和云端进行通信,并且将云端返回的数据同步到本地。

js
this.provider = new WebsocketProvider(url, roomname, this.doc);
Step2 创建客户端 WebSocket 连接

创建客户端连接,并监听message事件,当接收到服务端数据时,执行 readMessage 事件

js
export class WebsocketProvider extends Observable {
      const websocket = new provider._WS(provider.url, provider.protocols)
      websocket.onmessage = (event) => {
         provider.wsLastMessageReceived = time.getUnixTime()
         const encoder = readMessage(provider, new Uint8Array(event.data), true)
         if (encoding.length(encoder) > 1) {
           websocket.send(encoding.toUint8Array(encoder))
         }
       }
}
Step3 监听云端数据更新事件
js
const readMessage = (provider, buf, emitSynced) => {
    const messageHandler = provider.messageHandlers[messageType]
    messageHandler(encoder, decoder, provider, emitSynced, messageType)
}
messageHandlers[messageSync] = () => {
    // 又看到 readSyncMessage 这个函数了,剩下的就是执行 update 函数啦
    const syncMessageType = syncProtocol.readSyncMessage(decoder,encoder,provider.doc,provider)
}
Step4 剩下的就是回到官方的 Step4

总结

整体来说,好多工作 y-websocket 已经帮我们做了,我们只需要做两件事情:

  1. 创建 ws 服务实例,并监听连接方法;
  2. 监听 ws 的消息,并将数据直接返回其他客户端。

最简洁的服务端示例 ​

js
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({  port: 9999 });

wss.on("connection", (conn, req) => {
  // 监听消息并广播给其他客户端
  conn.onmessage = (event) => wss.clients.forEach((c) => c.send(event.data));
  /**
   * 监听关闭连接事件,
   * 这里广不广播都一样,
   * 因为可以直接客户端处理,
   * 客户端有一个 provider.emit('connection-close', [event, provider])
   */
  conn.onclose = () => console.warn("用户关闭连接\n")
});

技术选型及注意事项 ​

Yjs 官网给我们提供了上诉6中数据协同类型,Map、Array、Text、XmlElement、XmlFragment、XmlText。
  1. Map 底层是this._map = new Map(),因此适合做键值对存储。
  2. Array 底层是this._yarray = new YArray(), 就是普通的数组对象,因此适合做数组存储,使用下标进行存取值。
  3. Text 适合做文本存储,使用文本的插入、删除、替换等操作,应用场景为富文本(例如 Quill)。
  4. Xml 则接触的比较少,就不做介绍了。

提示

本应用中使用的使用的方案是:Map

js
// 下面是最常用的 设置、获取、删除 操作
ymap.set('prop-name', 'value') // value can be anything json-encodable
ymap.get('prop-name') // => 'value'
ymap.delete('prop-name')

// 判断 Map 中是否包含 key
ymap.has(key: string): boolean

// 清空 Map
ymap.clear()

// Map 的遍历操作
ymap.forEach(value: any, key: string, map: Y.Map)

ymap.entries(): Iterator // 返回 key-value 对

ymap.values(): Iterator // 仅返回 value

ymap.keys(): Iterator // 仅返回 key

// 监听 Map 的变化
ymap.observe(function(YMapEvent, Transaction))
ymap.unobserve(function) // 取消监听

// 深度监听 - 可监听 value 对象的对象的...的变化
ymap.observeDeep(function(Array<Y.Event>, Transaction))
ymap.unobserveDeep(function) // 取消监听

/**
 * 监听事件说明
 */
ymap.observe(ymapEvent => {
  //  yevent.changes.keys: Map<string, { action: 'add' | 'update' | 'delete', oldValue: any }>
  ymapEvent.changes.keys.forEach((change, key) => {
    // 直到是哪个key变化了,直接通过 map.get(key) 拿到对应的更新后的值,进行后续操作
  })
})

特别注意!

  1. 既然使用的 Map 类型,那么就要注意 key 值的使用了,因为同一个 key 值,只能对应一个值。
  2. 一个 key ,会引起 observe 'add' | 'update' | 'delete' 三个操作。
  3. 如果同时对一个 key 进行操作,Map 只能存储最后一个记录。
js
/**
 *  例如属性协同,用的 key 是 'shape_updateOptions':
 *    用户A:设置颜色为 blue  
 *      ==> this.ymap.set('shape_updateOptions', { fillColor: 'blue' })
 * 
 *    用户B:设置边框为 yellow 
 *      ==> this.ymap.set('shape_updateOptions', { border: 'yellow' })
 * 
 *    用户C:设置宽度为 100  
 *      ==> this.ymap.set('shape_updateOptions', { width: 100} )
 * 
 *    那么,此时,map 中,key 为 'shape_updateOptions' 的值,为:{ width:100 }
 *    后续用户登录查看的 shape 属性,为:width:100,其他属性会丢失!
 */
  1. 解决此问题办法:
js
/**
 * 1. 将用户的操作合并到一个对象中,进行存储
 *    
 *    用户A:设置颜色为 blue  
 *      ==> this.ymap.set('shape_updateOptions', { fillColor: 'blue' })
 *    
 *    用户B:设置边框为 yellow 
 *      ==> this.ymap.set('shape_updateOptions', { fillColor: 'blue', border: 'yellow' })
 * 
 *    用户C:设置宽度为 100  
 *      ==> this.ymap.set('shape_updateOptions', { fillColor: 'blue', border: 'yellow', width: 100} )
 * 
 * 2. 使用不同的key:
 * 
 *   用户A:设置颜色为 blue  
 *      ==> this.ymap.set('shape_updateOptions_fillColor', { fillColor: 'blue' })
 * 
 *    用户B:设置边框为 yellow 
 *      ==> this.ymap.set('shape_updateOptions_border', { border: 'yellow' })
 * 
 *    用户C:设置宽度为 100  
 *      ==> this.ymap.set('shape_updateOptions_width', { width: 100} )
 * 
 *  上诉两个解决办法均能保留属性,方法一适用于能够统一获取属性的场景,方法二适用于无法统一获取属性的场景。
 */

内存泄漏问题 ​

注意 Map 的内存泄漏问题

  1. 如果!如果确切知晓 key 的值在后续的操作中不会再应用到,请先执行 this.ymap.delete(key) 删除 key,避免内存泄漏。

  2. 请避免对同一个 key 多次设置不同的值,因为每次设置都会创建一个新对象,导致内存泄漏。

  3. 如果避免不了,请设置前,先删除同 key 值,再设置。

  4. 避免对同一个属性值,设置不同的 key,应该对属性值进行合并后,设置一个 key 值,并引发 update 操作。

优劣对比 ​

  1. 官方服务端肯定方便快捷啦,直接启动一个服务即可。

  2. 但是其缺点也比较明显,对于项目层面来说,其可定制化能力几乎为零。

  3. 自定义服务端,可以做到自定义属性、控制访问权限、自定义数据格式、自定义数据处理逻辑等,其灵活性较高,较为推荐。

温馨提示

  1. 当然,这只是停留在技术分析层面,具体的应用需要根据实际需求来选择。

  2. 还是推荐大家阅读官方文档,官方文档有详细的介绍,并且有详细的代码示例。

  3. 如果对协同部分还有啥不明白的,可以看看我的专题文章 协同编辑

欢迎大家留言交流,一起学习,一起进步。

Released under the MIT License.