实时协作的艺术:构建高效共享白板系统

2,444 阅读12分钟

前面看到一个共享白板的场景,如:发给你一个地址,让你在上面做题,双方都可以进行实时编辑。这个功能小而美,这里简单实现下。

1. 多人协同关键技术

实现多人协同编辑共享白板的功能,可以用到以下几种关键技术:

  1. WebSockets: WebSockets 提供了一个全双工的通信渠道,允许数据在客户端和服务器之间实时、快速地双向传输。这对于多人实时协作尤其重要。

  2. Operational Transformation (OT): 这是一种算法,用于同步和解决多个用户同时对同一个文档进行操作时可能产生的冲突。Google Docs 使用的就是这种技术。

  3. Conflict-free Replicated Data Types (CRDTs): CRDTs 是一种数据结构,它允许同时在多个设备上对数据进行修改,并能够确保最终的一致性,不需要复杂的冲突解决策略。

  4. Real-time Database: 如 Firebase Realtime Database 或 Amazon DynamoDB 等,这些数据库支持实时数据同步。

  5. Backend as a Service (BaaS): 服务如 Firebase、Azure 或 AWS Amplify 等,提供了实时数据库、身份验证、托管等服务,可以简化多人协同编辑功能的后端开发。

  6. REST API/GraphQL: 对于非实时的数据同步需求,可以使用 REST API 或 GraphQL 来处理数据的获取和更新。

  7. WebRTC: WebRTC 支持浏览器之间的点对点通信,可以用于支持实时音视频聊天等功能。

  8. Security: 使用 OAuth, JWT 等进行身份验证和授权,确保数据安全。

  9. Message Queue: 使用消息队列(如 RabbitMQ, Kafka)可以帮助实现高效、可靠的消息分发机制,适用于大规模的协作场景。

  10. Microservices Architecture: 采用微服务架构,可以将复杂的应用拆分成更容易管理和扩展的小模块。

  11. Load Balancing: 在服务器端使用负载均衡技术来分配客户端请求,确保系统的高可用性和伸缩性。

2. 算法对比:OT VS CRDTs

Operational Transformation (OT) 和 Conflict-free Replicated Data Types (CRDTs) 都是解决分布式系统中数据一致性问题的算法,但它们在理念和实现上有所不同。

Operational Transformation (OT):

  • 概念: OT 通过转换同时发生的操作来解决冲突,它需要跟踪每个操作的上下文信息,包括操作的位置和历史。
  • 实现: 通常需要一个中心服务器来进行操作转换和冲突解决,确保所有客户端最终都看到相同的文档状态。
  • 优点: 在某些情况下,OT 可以提供更精细的控制和更多的协作特性。
  • 缺点: 实现复杂,对网络延迟敏感。

Conflict-free Replicated Data Types (CRDTs):

  • 概念: CRDTs 通过确保所有操作都是可交换的(Commutative)和可合并的(Associative)来避免冲突,不需要中心服务器来解决冲突。
  • 实现: 客户端可以独立地执行操作,并通过一种最终一致性的方式来同步更新。
  • 优点: 结构简单,适合离线和网络不稳定的环境。
  • 缺点: 可能无法支持某些需要精确控制操作顺序的协作特性。

以下是基于 Node.js 的简化示例,展示 OT 和 CRDTs 的基本概念:

OT 示例 (Node.js):

// 假设有两个操作:用户 A 在位置0插入字符"a",用户 B 在位置0插入字符"b"。
// 服务器需要转换其中一个操作,以保持一致性。

// 初始字符串
let document = "";

// 用户 A 的操作
const opA = { position: 0, char: "a", type: "insert" };

// 用户 B 的操作
const opB = { position: 0, char: "b", type: "insert" };

// OT 转换函数
function transform(op1, op2) {
  if (
    op1.position === op2.position &&
    op1.type === "insert" &&
    op2.type === "insert"
  ) {
    op1.position += 1;
  }
  return op1;
}

// 服务器转换操作
const transformedOpA = transform(opA, opB);

// 应用操作
function applyOp(op) {
  document =
    document.substring(0, op.position) +
    op.char +
    document.substring(op.position);
}

// 按顺序应用操作
applyOp(opB);
applyOp(transformedOpA);

console.log(document); // 输出 "ba"

CRDTs 示例 (Node.js):

// 假设我们有一个可合并的计数器 CRDT,任何节点都可以独立增加计数器,然后通过某种机制合并。

class GCounter {
  constructor() {
    this.counts = {};
  }

  // 增加当前节点的计数
  increment(nodeId) {
    if (!this.counts[nodeId]) {
      this.counts[nodeId] = 0;
    }
    this.counts[nodeId] += 1;
  }

  // 合并来自其他节点的计数器
  merge(otherCounter) {
    for (let nodeId in otherCounter.counts) {
      if (
        !this.counts[nodeId] ||
        this.counts[nodeId] < otherCounter.counts[nodeId]
      ) {
        this.counts[nodeId] = otherCounter.counts[nodeId];
      }
    }
  }

  // 获取总计数
  getValue() {
    return Object.values(this.counts).reduce((sum, count) => sum + count, 0);
  }
}

// 创建两个节点的计数器
const counterA = new GCounter();
const counterB = new GCounter();

// 节点 A 和节点 B 独立计数
counterA.increment("A");
counterB.increment("B");

// 合并计数器状态
counterA.merge(counterB);
counterB.merge(counterA);

console.log(counterA.getValue()); // 输出 "2"
console.log(counterB.getValue()); // 输出 "2"

在上述两个示例中,OT 示例展示了在文本协作编辑场景中如何使用操作转换来保持文档一致性。而 CRDTs 示例则展示了在计数器场景下如何使用可合并的计数器来确保最终一致性,无需考虑操作的顺序。

需要注意,以上示例仅用于描述 OT 和 CRDTs 的基本概念,并非完整的协同编辑系统实现。在实际的多人协同编辑应用中,OT 和 CRDTs 的实现会更加复杂,涉及到更多的边界情况和性能优化。

3. 传输对比:WebSockets VS WebRTC

WebSockets 和 WebRTC 是两种不同的技术,它们都可以在浏览器中用于实现实时通信,但是它们的设计目标、用例和实现方式存在显著差异。

WebSockets:

  1. 基本概念:WebSockets 提供了一个全双工的通信协议,允许客户端与服务器之间建立一个持久的连接,并通过这个连接进行实时的数据交换。

  2. 特点

    • 基于 TCP 连接。
    • 协议标识符为 ws:// 或加密的 wss://
    • 适用于客户端和服务器之间的任意类型的数据传输。
    • 没有内置的直接点对点(P2P)通信能力;所有通信都是通过服务器进行中转的。
  3. 优势

    • 降低了服务器和客户端之间通信的延迟。
    • 支持文本和二进制数据的传输。
    • 被广泛支持,大多数现代浏览器都提供了原生的 WebSocket 支持。
  4. 劣势

    • 不适合用于 P2P 场景,因为数据传输需要通过服务器中转。
    • 相比于 HTTP 轮询,WebSocket 更复杂,需要服务器端支持 WebSocket 协议。

WebRTC:

  1. 基本概念:WebRTC(Web Real-Time Communication)是一个支持网页浏览器进行实时语音对话、视频对话和点对点文件共享的技术。

  2. 特点

    • 提供了 P2P 直接通信的能力,不需要服务器中转数据(除了在建立连接时需要信令服务器)。
    • 协议标准复杂,包括了一组不同的 API 和协议。
    • 支持数据通道(Data Channels)、音频和视频流。
    • 通信可以是加密的,且通常建立在 UDP 上,以减少延迟。
  3. 优势

    • 适合于视频/语音通话、实时游戏、P2P 文件共享等高实时性和带宽敏感的应用。
    • 减少服务器带宽需求,因为数据可以直接在用户之间传输。
    • 支持 NAT 穿透和加密,安全性较高。
  4. 劣势

    • 实现复杂,需要处理 NAT 穿透、会话控制协议(如 ICE、STUN、TURN)等。
    • 对于非实时、高可靠性要求的数据传输场景可能不是最佳选择。

适合的场景

  • WebSockets

    • 聊天应用:需要服务器存储和转发消息。
    • 多人在线游戏:服务器维护游戏逻辑和状态。
    • 实时数据更新:如股票行情、新闻直播等。
    • 协同工作应用:如文档编辑、项目管理工具等。
  • WebRTC

    • 视频/语音通话:如 Skype 的网页版。
    • 实时 P2P 游戏:需要低延迟的双方通讯。
    • 私密文件共享:直接在用户间传输数据,不通过服务器。
    • 实时音视频直播:支持大规模的 P2P 数据流传输。

总的来说,WebSockets 主要用于需要服务器处理逻辑的实时通信,而 WebRTC 适用于需要直接点对点通信,且对延迟和带宽有更高要求的场景。根据应用的需求和设计,可以选择合适的技术来实现。

4. 简版实现

这里直接采用 WebSockets 简单实现一下。

转存失败,建议直接上传图片文件

4.1 Server

// 从相应的包中导入所需模块。
import express from "express";
import http from "http";
import WebSocket, { WebSocketServer } from "ws";

// 创建一个新的 Express 应用实例。
const app = express();

// 使用 Express 应用创建一个新的 HTTP 服务器。
const server = http.createServer(app);

// 初始化一个新的 WebSocket 服务器实例并将其挂载到 HTTP 服务器上。
const wss = new WebSocketServer({ server });

// 设置 Express 应用以从"public"目录提供静态文件。
app.use(express.static("public"));

// 为 WebSocket 服务器的'connection'事件定义行为。
wss.on("connection", function connection(ws) {
  // 通知所有客户端有新用户连接。
  const userConnected = {
    type: "user-event",
    event: "connected",
    userCount: wss.clients.size, // 包含当前用户数量。
  };
  // 将用户连接事件广播给所有客户端(除了新连接的用户)。
  broadcast(ws, JSON.stringify(userConnected), false);

  // 为 WebSocket 实例的'message'事件定义行为。
  ws.on("message", function incoming(data) {
    // 将接收到的消息广播给所有客户端。
    broadcast(ws, data.toString());
  });

  // 为 WebSocket 实例的'close'事件定义行为。
  ws.on("close", function close() {
    // 通知所有客户端用户已断开连接。
    const userDisconnected = {
      type: "user-event",
      event: "disconnected",
      userCount: wss.clients.size - 1, // 更新用户数量。
    };
    // 将用户断开连接事件广播给所有客户端。
    broadcast(ws, JSON.stringify(userDisconnected), false);
  });
});

// 在8083端口启动 HTTP 服务器。
server.listen(8083, function () {
  console.log("服务器正在 http://localhost:8083 上运行");
});

// 定义一个函数来向所有已连接的 WebSocket 客户端广播数据。
function broadcast(client, data, excludeSelf = true) {
  // 遍历所有已连接的 WebSocket 客户端。
  wss.clients.forEach(function each(otherClient) {
    // 检查是否排除发送者以及其他客户端的 WebSocket 是否处于开放状态。
    if (
      excludeSelf
        ? otherClient !== client && otherClient.readyState === WebSocket.OPEN
        : true
    ) {
      // 向其他客户端发送数据。
      otherClient.send(data);
    }
  });
}

4.2 浏览器端

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>共享白板</title>
  <!-- 页面的样式,设置了 html 和 body 的高度和边距,以及文本域的宽度和高度 -->
  <style>
    html,
    body {
      height: 100%;
      margin: 0;
      padding: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
    }
    h1 {
      height: 5vh;
    }
    textarea {
      width: 90vw;
      height: 80vh;
      outline: none;
    }
  </style>
</head>
<body>
  <!-- 页面标题,其中包含在线人数的显示 -->
  <h1>共享白板:(<span id="count"></span>)</h1>
  <!-- 可供多人共享编辑的文本域 -->
  <textarea id="sharedTextarea" rows="10" cols="50"></textarea>
  <!-- 引入 lodash 库,用于函数防抖 -->
  <script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/lodash.js/4.17.21/lodash.min.js"
    crossorigin="anonymous"></script>
  <script>
// 创建 WebSocket 连接,连接到当前页面的主机地址
const ws = new WebSocket('ws://' + location.host);

// 当从 WebSocket 收到消息时的处理逻辑
ws.onmessage = async function (event) {
    // 解析收到的消息
    const message = JSON.parse(event.data);

    // 判断消息类型
    if (message.type === 'text-update') {
        // 如果是文本更新消息,则更新文本域的内容及光标位置
        const textarea = document.getElementById('sharedTextarea');
        textarea.focus();
        textarea.value = message.content;
        // 更新光标位置
        textarea.selectionStart = message.selectionStart;
        textarea.selectionEnd = message.selectionEnd;
    } else if (message.type === 'user-event') {
        // 如果是用户事件(连接或断开),更新在线人数显示
        console.log('User event:', message.event, 'Current user count:', message.userCount);
        document.getElementById('count').innerText = message.userCount + '人在线';
    }
};

// 当 WebSocket 连接关闭时的处理逻辑
ws.onclose = function () {
    console.log('WebSocket connection closed');
    document.getElementById('count').innerText = '已离线';
};

// 发送更新内容到服务器的函数
async function sendUpdate() {
    const textarea = document.getElementById('sharedTextarea');
    const content = textarea.value
    const message = {
        type: 'text-update',
        content,
        selectionStart: textarea.selectionStart,
        selectionEnd: textarea.selectionEnd
    };
    // 通过 WebSocket 发送更新消息
    ws.send(JSON.stringify(message));
}

// 使用 lodash 的防抖函数,限制 sendUpdate 函数的触发频率
const dSendUpdate = _.debounce(sendUpdate, 500)

// 给文本域添加事件监听,当输入、鼠标释放和键盘释放事件发生时触发更新
document.getElementById('sharedTextarea').addEventListener('input', dSendUpdate);
document.getElementById('sharedTextarea').addEventListener('mouseup', dSendUpdate);
document.getElementById('sharedTextarea').addEventListener('keyup', dSendUpdate);
</script>
</body>
</html>

这段代码构成了一个简单的共享白板 HTML 页面,用户可以在文本域中输入并实时与其他用户共享内容。通过 WebSocket 通信机制,用户的每次输入都会被发送到服务器,并由服务器广播给其他用户,实现协编辑的效果。同时,页面还能够显示当前在线的用户数量。使用了 lodash 库中的debounce函数来限制发送更新的频率,避免因为频繁通信而导致的性能问题。

4.3 简单加密/解密处理

// 定义一个异步函数用于加密文本。
async function en(textToEncrypt) {
  // 使用 TextEncoder 将文本转换为 UTF-8编码的字节。
  const encoder = new TextEncoder();
  const data = encoder.encode(textToEncrypt);

  // 从 localStorage 中读取 AES 密钥,将十六进制字符串转换为字节数组。
  const keyBuffer = new Uint8Array(
    localStorage
      .getItem("aesKey")
      .match(/[\da-f]{2}/gi)
      .map((h) => parseInt(h, 16))
  );

  // 从 localStorage 中读取初始化向量(IV),将十六进制字符串转换为字节数组。
  const ivBuffer = new Uint8Array(
    localStorage
      .getItem("aesIv")
      .match(/[\da-f]{2}/gi)
      .map((h) => parseInt(h, 16))
  );

  // 导入密钥,用于后续的加密操作。
  const key = await window.crypto.subtle.importKey(
    "raw", // 密钥数据的格式。
    keyBuffer, // 密钥数据。
    "AES-CBC", // 使用的加密算法。
    true, // 密钥是否可导出。
    ["encrypt", "decrypt"] // 密钥用途。
  );

  // 执行加密操作。
  const encryptedData = await window.crypto.subtle.encrypt(
    {
      name: "AES-CBC", // 使用的加密算法。
      iv: ivBuffer, // 初始向量。
    },
    key, // 导入的密钥。
    data // 要加密的数据。
  );

  // 将加密后的数据转换为 Base64字符串。
  return btoa(String.fromCharCode.apply(null, new Uint8Array(encryptedData)));
}

// 定义一个异步函数用于解密 Base64字符串。
async function de(base64String) {
  // 将 Base64字符串转换为字节数组。
  const encryptedData = new Uint8Array(
    atob(base64String)
      .split("")
      .map((char) => char.charCodeAt(0))
  );

  // 从 localStorage 中读取 AES 密钥,将十六进制字符串转换为字节数组。
  const keyBuffer = new Uint8Array(
    localStorage
      .getItem("aesKey")
      .match(/[\da-f]{2}/gi)
      .map((h) => parseInt(h, 16))
  );

  // 从 localStorage 中读取初始化向量(IV),将十六进制字符串转换为字节数组。
  const ivBuffer = new Uint8Array(
    localStorage
      .getItem("aesIv")
      .match(/[\da-f]{2}/gi)
      .map((h) => parseInt(h, 16))
  );

  // 导入密钥,用于后续的解密操作。
  const key = await window.crypto.subtle.importKey(
    "raw", // 密钥数据的格式。
    keyBuffer, // 密钥数据。
    "AES-CBC", // 使用的加密算法。
    true, // 密钥是否可导出。
    ["encrypt", "decrypt"] // 密钥用途。
  );

  // 执行解密操作。
  const decryptedData = await window.crypto.subtle.decrypt(
    {
      name: "AES-CBC", // 使用的加密算法。
      iv: ivBuffer, // 初始向量。
    },
    key, // 导入的密钥。
    encryptedData // 要解密的数据。
  );

  // 使用 TextDecoder 将解密后的字节数据转换为文本。
  const decoder = new TextDecoder();
  return decoder.decode(decryptedData);
}

详细参考:github.com/lecepin/web…


微信搜索“好朋友乐平”关注公众号。

github原文地址