挑战 Google Docs!Spring Boot + WebSocket + OT 算法实现“多人在线协同文档”系统(难点攻克版)
🤯 前言:为什么这玩意儿这么难?
想象一个场景:
文档内容是 "Hello World"。
- 用户 A 想在位置 0 插入
"Java "-> 变成"Java Hello World"。 - 用户 B 同时想在位置 6 删除
"World"-> 变成"Hello "。
如果没有算法介入,服务器只是简单地执行这两个命令:
- A 执行完:
"Java Hello World" - B 的命令是“删除位置 6 的 5 个字符”。
- 结果:删掉了
"Hello",变成了"Java World"。
错啦!用户 B 明明想删的是"World"!
这就是并发意图冲突。要解决它,必须引入 OT 算法。
🧠 一、 核心魔法:OT 算法图解
OT 的核心思想是:基于已经发生的操作,转换(Transform)当前的操作。
如果服务器先执行了 A 的“插入”,那么 B 的“删除”操作必须发生变化:
因为 A 在前面插入了 5 个字符("Java "),所以 B 的删除位置必须 +5。
OT 转换流程图 (Mermaid):
公式表达:
意思是:在 已经执行的情况下,把 转换成 ,使其能达到预期的效果。
🏗️ 二、 系统架构设计
我们需要一个全双工的通信通道,WebSocket 是不二之选。
- Frontend: 监听用户输入,生成 Operation (Insert/Delete),发送给后端。同时接收后端推过来的 Operation 并应用。
- Backend (Spring Boot): 维护文档的版本号 (Version) 和操作历史 (History)。
- OT Engine: 核心算法层,负责“变造”操作。
💻 三、 硬核代码:手写 OT 算法 (Java 版)
为了演示,我们简化模型,只处理两种操作:Insert 和 Delete。
定义操作对象:
public class Operation {
public enum Type { INSERT, DELETE }
public Type type;
public int position;
public String text; // 插入的内容
public int length; // 删除的长度
public int version; // 基于哪个版本发出的操作
}
OT 转换核心逻辑 (Transform):
public static Operation transform(Operation opB, Operation opA) {
// 复制一份 opB 作为结果
Operation opBPrime = new Operation(opB);
if (opA.type == Operation.Type.INSERT) {
// Case 1: A 插入,B 插入/删除
// 如果 A 插入的位置在 B 操作位置之前,B 的位置需要后移
if (opA.position <= opB.position) {
opBPrime.position = opB.position + opA.text.length();
}
} else if (opA.type == Operation.Type.DELETE) {
// Case 2: A 删除,B 插入/删除
// 如果 A 删除的位置在 B 之前
if (opA.position < opB.position) {
// 需要减去 A 删除的长度,但要处理重叠情况(这里简化处理)
opBPrime.position = Math.max(opA.position, opB.position - opA.length);
}
}
return opBPrime;
}
注:真实场景的 OT 还要处理 Retain(保持)操作和复杂的字符串重叠逻辑,这里展示的是核心灵魂。
🔌 四、 Spring Boot WebSocket 集成
1. 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2. WebSocket 处理器
我们需要处理并发。当多个用户同时发来操作时,必须排队处理(使用 synchronized 或单线程队列)。
@Component
public class DocHandler extends TextWebSocketHandler {
// 模拟数据库:存储文档内容
private StringBuilder document = new StringBuilder("");
// 存储操作历史,用于版本对齐
private List<Operation> history = new CopyOnWriteArrayList<>();
// 当前文档版本
private AtomicInteger version = new AtomicInteger(0);
// 所有的 Session
private List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 1. 解析客户端发来的 Operation
Operation clientOp = json.parse(message.getPayload());
synchronized (this) {
// 2. 版本检查 (关键步骤!)
// 如果客户端的版本(clientOp.version) < 当前服务器版本(this.version)
// 说明服务器已经处理了其他人的操作,客户端落后了
if (clientOp.version < this.version.get()) {
// 3. 追溯历史,进行连续变换
for (int i = clientOp.version; i < this.version.get(); i++) {
Operation pastOp = history.get(i);
// 核心:把客户端的过时操作,变成新操作
clientOp = OTUtils.transform(clientOp, pastOp);
}
}
// 4. 应用操作到文档
applyToDocument(clientOp);
// 5. 记录历史,版本+1
history.add(clientOp);
this.version.incrementAndGet();
// 6. 广播给所有人 (包括发送者,确认操作已生效)
broadcast(clientOp);
}
}
private void broadcast(Operation op) {
// 发送 JSON: { type: "BROADCAST", op: ... }
// 前端收到后,根据 op 更新自己的编辑器
}
}
🧩 五、 前端的挑战:光标不能乱跳
后端做好了,前端其实更难。前端通常使用 CodeMirror 或 Monaco Editor。
当后端发来一个 Insert 指令时,你不能直接 setValue(newText),那样用户的光标会回到开头。
你需要使用编辑器提供的 API:
- Monaco:
executeEdits - CodeMirror:
replaceRange
同时,前端也需要一套 OT 逻辑,来处理“我发出的操作还在路上,服务器又推来了一个新操作”的 Pending 状态。
🎯 总结与进阶
通过 Spring Boot + WebSocket + 简易 OT,我们实现了一个多人文档的雏形。但要做到 Google Docs 的级别,还有几座大山:
- GOTO 算法 (Inclusion Transformation): 处理更复杂的网状并发。
- CRDT (无冲突复制数据类型): 现代协同软件(如 Figma)更倾向于用 CRDT 代替 OT,因为它去中心化,但由于数据结构膨胀,文本编辑场景下 OT 依然是王。
- 光标协同: 把别人的光标位置也广播出来,显示名字标签。
面试必杀技:
如果你能在面试中讲清楚 “客户端版本落后时,如何利用 OT 算法追溯历史操作进行 Transform”,面试官绝对会对你刮目相看。这是真正的技术深水区。
Next Step:
不要只看,去 GitHub 找一个开源的 OT 库(如 ot-java),把它塞进你的 Spring Boot 项目里,看着两个浏览器窗口里的字同步跳动,那种成就感无与伦比!
更多推荐


所有评论(0)