-
Notifications
You must be signed in to change notification settings - Fork 25
Description
实现操作转换(OT)是一个复杂但富有挑战性的过程。OT 的核心目标是在多用户实时编辑时,通过转换并发操作,保证所有用户最终看到一致的文档状态。以下是 OT 的实现框架、核心算法和关键步骤,帮助你理解如何从零构建一个简单的 OT 系统。
OT 的核心概念
在实现 OT 之前,需明确以下关键概念:
-
操作(Operation):用户对文档的修改行为(如插入、删除、格式调整),需包含:
type:操作类型(insert、delete、format等);position:操作在文档中的位置(索引);content:操作内容(如插入的文本、格式类型);version:操作基于的文档版本(用于冲突检测)。
-
文档状态(Document State):文档的当前内容(如文本字符串、JSON 结构),每个状态对应一个唯一版本号。
-
操作转换(Transformation):当两个用户的操作并发时,需将后收到的操作转换为「与先执行操作兼容」的新操作,再应用到当前文档。
-
版本向量(Version Vector):记录每个用户的操作历史,用于更精细的冲突检测(可选,适用于分布式场景)。
OT 的核心算法:操作转换函数
OT 的核心是转换函数(transform),它接收两个并发操作(op1 和 op2),返回转换后的新操作(op1' 和 op2'),确保两者先后执行的结果一致。
转换函数需处理不同操作类型的组合(如 insert 与 insert、insert 与 delete、delete 与 delete),以下是最常见的文本编辑场景的转换规则:
1. 文本编辑的操作定义
假设文档是字符串,操作类型为 insert 和 delete:
insert(pos, text):在位置pos插入文本text;delete(pos, length):从位置pos开始删除length个字符。
2. 转换规则(核心)
转换函数的设计需遵循「先执行一个操作,再转换另一个操作,结果与顺序无关」的原则。以下是具体规则:
规则 1:insert 与 insert 冲突
当两个 insert 操作在同一位置插入内容时,转换规则为:
- 假设
op1 = insert(p1, t1),op2 = insert(p2, t2); - 若
p1 < p2:op2的插入位置需后移len(t1)(因为op1插入的内容占了len(t1)个位置),即op2' = insert(p2 + len(t1), t2);op1无需转换(op1' = op1); - 若
p1 > p2:op1的插入位置需后移len(t2),即op1' = insert(p1 + len(t2), t1);op2无需转换(op2' = op2); - 若
p1 == p2:按用户 ID 或操作时间戳排序(避免歧义),后插入的操作位置后移前一个操作的文本长度。
示例:
- 初始文档:
"ABC"(版本 0); op1 = insert(2, "X")(在位置 2 插入 "X");op2 = insert(2, "Y")(在位置 2 插入 "Y");- 转换后:
op1' = insert(2, "X"),op2' = insert(3, "Y")(因为op1插入的 "X" 占 1 个位置,op2的位置从 2 变为 3); - 执行顺序 1:
op1'→op2'→ 文档变为"AXYC"; - 执行顺序 2:
op2→op1'(op1转换为insert(3, "X"))→ 文档变为"AYXC"? 这里存在歧义,需补充规则:当p1 == p2时,按预设优先级(如用户 ID 小的在前),确保转换后顺序一致。例如,若op1的用户 ID 小于op2,则op2位置后移len(t1),最终结果统一为"AXYC"。
规则 2:insert 与 delete 冲突
假设 op1 = insert(p1, t1),op2 = delete(p2, l2):
- 若
p1 <= p2:op2的删除位置需后移len(t1)(因为op1插入的内容在op2删除位置之前,占了len(t1)个位置),即op2' = delete(p2 + len(t1), l2);op1无需转换; - 若
p1 > p2:- 若
p1 <= p2 + l2:op1的插入位置需前移l2(因为op2删除的内容覆盖了op1插入位置的一部分,删除后op1位置提前),即op1' = insert(p1 - l2, t1);op2无需转换; - 若
p1 > p2 + l2:op1和op2无重叠,无需转换。
- 若
示例:
- 初始文档:
"ABCDE"(版本 0); op1 = insert(3, "X")(在位置 3 插入 "X");op2 = delete(1, 2)(从位置 1 删除 2 个字符,即删除 "BC");- 分析:
p1 = 3,p2 = 1,l2 = 2,p1 > p2且p1 <= p2 + l2(3 <= 1+2=3); - 转换后:
op1' = insert(3 - 2, "X") = insert(1, "X");op2' = delete(1, 2); - 执行顺序 1:
op2'→ 删除 "BC" 后文档变为"ADE"→ 执行op1'→ 插入 "X" 后变为"AXDE"; - 执行顺序 2:
op1→ 插入 "X" 后文档变为"ABXCDE"→ 执行op2→ 删除位置 1 的 2 个字符("BX")→ 变为"ACDE"? 这里明显矛盾,说明转换规则需调整。正确的逻辑应该是:当op2是删除操作时,若op1的插入位置在op2的删除范围内(p2 <= p1 <= p2 + l2),则op1被删除,无需转换;若在删除范围之后,则op1位置前移l2。重新分析:op2 = delete(1, 2)删除的是位置 1-2("BC");op1 = insert(3, "X")的位置 3 在删除范围之后(1+2=3),所以op1位置前移 2 →insert(1, "X");- 执行顺序 2:
op1→"ABXCDE"→op2删除位置 1-2("BX")→"ACDE",这与顺序 1 的结果不一致,说明规则需优化。正确的转换规则应为:delete 操作会影响后续 insert 的位置,而 insert 操作不会影响 delete 的位置(因为 delete 是删除已有内容,insert 是新增内容)。因此,正确的转换应为:- 当
op1是 insert,op2是 delete 时:- 若
p1 < p2:op2位置后移len(t1); - 若
p1 >= p2:op2位置不变(因为 delete 是删除op1之前或之后的内容,不影响op1的插入位置); op1的位置:若p1 > p2 + l2,则前移l2;否则不变(因为op1插入在 delete 范围之后,delete 会缩短文档长度)。
修正后示例:
- 若
- 当
op1 = insert(3, "X"),op2 = delete(1, 2);op2转换:p2 = 1,op1的p1 = 3>p2,所以op2位置不变 →op2' = delete(1, 2);op1转换:p1 = 3>p2 + l2 = 3? 不,p2 + l2 = 1 + 2 = 3,p1等于 3,属于 delete 范围的末尾,所以op1位置前移l2→insert(1, "X");- 执行顺序 1:
op2'→"ADE"→op1'→"AXDE"; - 执行顺序 2:
op1→"ABXCDE"→op2转换为delete(1, 2)(因为op1插入在位置 3,op2的删除位置 1 不受影响)→ 删除 "BC" →"AXCDE"。 显然仍有问题,这说明 OT 转换规则的设计需要更严谨的数学证明,实际实现中建议参考成熟的 OT 算法(如 Google Wave 的 OT 算法)。
规则 3:delete 与 delete 冲突
假设 op1 = delete(p1, l1),op2 = delete(p2, l2):
- 若
p1 + l1 <= p2:无重叠,无需转换; - 若
p2 + l2 <= p1:无重叠,无需转换; - 若有重叠:
- 取两个删除范围的交集
[max(p1, p2), min(p1 + l1, p2 + l2)); - 若
op1的删除范围在op2之前(p1 < p2):op2的删除位置需前移l1 - overlap_length(因为op1删除了部分内容,op2的位置提前); - 若
op2的删除范围在op1之前(p2 < p1):op1的删除位置需前移l2 - overlap_length; - 若完全重叠:后执行的操作删除长度为 0(无需操作)。
- 取两个删除范围的交集
示例:
- 初始文档:
"ABCDEF"(版本 0); op1 = delete(1, 3)(删除位置 1-3,即 "BCD");op2 = delete(2, 2)(删除位置 2-3,即 "CD");- 重叠范围:
[2, 3)(长度 1); - 转换
op2:p2 = 2,op1删除了位置 1-3,op2的位置在op1范围内,所以op2的删除长度变为2 - 1 = 1(重叠部分已被op1删除),即op2' = delete(2, 1)(但op1执行后文档变为"AEF",位置 2 已无内容,所以op2'实际无效); - 转换
op1:p1 = 1,op2删除了位置 2-3,op1的删除范围包含op2,所以op1的删除长度变为3 - 2 = 1(op2已删除 2 个字符),即op1' = delete(1, 1)(删除 "B"); - 执行顺序 1:
op1'→ 删除 "B" 后文档变为"ACDEF"→ 执行op2'→ 删除位置 2 的 1 个字符("D")→ 变为"ACEF"; - 执行顺序 2:
op2→ 删除 "CD" 后文档变为"ABEF"→ 执行op1'→ 删除位置 1 的 1 个字符("B")→ 变为"AEF"。 这里仍有问题,说明 delete-delete 冲突的转换规则最复杂,需结合具体场景细化。
OT 的实现步骤(简化版)
以下是一个简化的 OT 系统实现框架,包含「操作定义、转换函数、文档状态管理、网络同步」四个核心模块:
1. 定义操作类(Operation)
class Operation {
constructor(type, position, content, version) {
this.type = type; // "insert" 或 "delete"
this.position = position; // 操作位置
this.content = content; // 插入的文本(insert)或删除的长度(delete)
this.version = version; // 操作基于的文档版本
}
// 序列化操作(用于网络传输)
toJSON() {
return {
type: this.type,
position: this.position,
content: this.content,
version: this.version,
};
}
// 反序列化操作
static fromJSON(json) {
return new Operation(json.type, json.position, json.content, json.version);
}
}2. 实现转换函数(transform)
function transform(op1, op2) {
if (op1.type === "insert" && op2.type === "insert") {
// insert 与 insert 冲突
if (op1.position < op2.position) {
// op2 位置后移 op1 文本长度
return [op1, new Operation("insert", op2.position + op1.content.length, op2.content, op2.version)];
} else if (op1.position > op2.position) {
// op1 位置后移 op2 文本长度
return [new Operation("insert", op1.position + op2.content.length, op1.content, op1.version), op2];
} else {
// 位置相同,按用户 ID 排序(假设 op1.userId < op2.userId)
return [op1, new Operation("insert", op2.position + op1.content.length, op2.content, op2.version)];
}
} else if (op1.type === "insert" && op2.type === "delete") {
// insert 与 delete 冲突
if (op1.position <= op2.position) {
// op2 位置后移 op1 文本长度
return [op1, new Operation("delete", op2.position + op1.content.length, op2.content, op2.version)];
} else {
// op1 位置前移 op2 删除长度(若在删除范围之后)
const newPosition = op1.position > op2.position + op2.content ? op1.position - op2.content : op1.position;
return [new Operation("insert", newPosition, op1.content, op1.version), op2];
}
} else if (op1.type === "delete" && op2.type === "insert") {
// delete 与 insert 冲突(对称于 insert 与 delete)
if (op2.position <= op1.position) {
// op1 位置后移 op2 文本长度
return [new Operation("delete", op1.position + op2.content.length, op1.content, op1.version), op2];
} else {
// op2 位置前移 op1 删除长度(若在删除范围之后)
const newPosition = op2.position > op1.position + op1.content ? op2.position - op1.content : op2.position;
return [op1, new Operation("insert", newPosition, op2.content, op2.version)];
}
} else if (op1.type === "delete" && op2.type === "delete") {
// delete 与 delete 冲突(简化版)
const op1End = op1.position + op1.content;
const op2End = op2.position + op2.content;
const overlapStart = Math.max(op1.position, op2.position);
const overlapEnd = Math.min(op1End, op2End);
const overlapLength = overlapEnd - overlapStart;
if (overlapLength <= 0) {
// 无重叠,无需转换
return [op1, op2];
} else {
// 有重叠,调整后执行的操作长度
if (op1.position < op2.position) {
// op2 先执行,op1 调整长度
const newOp1Content = op1.content - overlapLength;
return [new Operation("delete", op1.position, newOp1Content, op1.version), op2];
} else {
// op1 先执行,op2 调整长度
const newOp2Content = op2.content - overlapLength;
return [op1, new Operation("delete", op2.position, newOp2Content, op2.version)];
}
}
}
}3. 文档状态管理(Document)
class Document {
constructor(initialContent = "") {
this.content = initialContent; // 文档内容
this.version = 0; // 文档当前版本
this.pendingOperations = []; // 待确认的本地操作(网络同步时使用)
}
// 应用操作到文档
applyOperation(op) {
if (op.version !== this.version) {
throw new Error("操作版本不匹配,需先转换");
}
if (op.type === "insert") {
this.content = this.content.slice(0, op.position) + op.content + this.content.slice(op.position);
} else if (op.type === "delete") {
this.content = this.content.slice(0, op.position) + this.content.slice(op.position + op.content);
}
this.version++;
return this.content;
}
// 处理远程操作(需先转换)
processRemoteOperation(remoteOp) {
// 1. 检查本地是否有待确认的操作(本地操作已执行,但服务器未响应)
const transformedOps = [];
let currentOp = remoteOp;
// 2. 对每个待确认的本地操作,转换远程操作
for (const localOp of this.pendingOperations) {
const [transformedLocal, transformedRemote] = transform(localOp, currentOp);
transformedOps.push(transformedLocal);
currentOp = transformedRemote;
}
// 3. 应用转换后的远程操作
this.applyOperation(currentOp);
// 4. 更新待确认的本地操作(若有)
this.pendingOperations = transformedOps;
return this.content;
}
// 发送本地操作到服务器(乐观更新)
sendLocalOperation(op) {
// 1. 本地先应用操作(乐观更新)
this.applyOperation(op);
// 2. 将操作加入待确认队列
this.pendingOperations.push(op);
// 3. 发送到服务器(网络请求)
// fetch("/api/operations", { method: "POST", body: JSON.stringify(op) });
return this.content;
}
// 收到服务器确认(移除待确认操作)
confirmLocalOperation(opId) {
this.pendingOperations = this.pendingOperations.filter(op => op.id !== opId);
}
}4. 网络同步(服务器端)
服务器的核心职责是:
- 接收用户发送的操作;
- 广播操作到其他用户;
- 维护操作日志(用于新用户加入时重放历史操作)。
简化的服务器伪代码:
// 服务器端:维护所有操作日志
const operationLog = [];
// 接收客户端操作
function handleClientOperation(op) {
// 1. 验证操作版本(可选,确保操作基于最新版本)
if (op.version !== operationLog.length) {
// 版本不匹配,返回冲突信息,客户端需重新转换
return { status: "conflict", currentVersion: operationLog.length };
}
// 2. 将操作加入日志
operationLog.push(op);
// 3. 广播操作到所有其他客户端
broadcastToClients(op);
return { status: "ok" };
}
// 新用户加入:返回历史操作日志
function getHistoryOperations() {
return operationLog;
}OT 的挑战与优化
-
复杂的转换规则:OT 的转换函数需覆盖所有操作类型的组合(insert/delete/format 等),且需严格保证一致性,调试难度大。实际实现中建议参考成熟的 OT 库(如
ot.js、ShareDB)。 -
版本管理:分布式场景下,需使用版本向量(而非单一版本号)记录每个用户的操作历史,避免版本冲突。
-
性能优化:
- 操作合并:将连续的小操作(如连续输入多个字符)合并为一个批量操作,减少网络传输和转换开销;
- 操作压缩:对操作日志进行压缩(如删除已被覆盖的旧操作),降低存储和传输成本。
-
用户体验:
- 乐观更新:本地操作立即生效,无需等待服务器响应;
- 远程光标:显示其他用户的光标位置,减少冲突概率;
- 冲突提示:当自动转换失败时,提示用户手动解决冲突。
总结
OT 的实现核心是「转换函数」,它通过调整并发操作的位置和长度,确保所有用户最终看到一致的文档状态。虽然简化版的 OT 系统可以通过上述步骤实现,但生产环境中建议使用成熟的 OT 库(如 ShareDB、ot.js),避免重复造轮子和潜在的一致性问题。
如果需要处理更复杂的场景(如富文本格式、分布式协同),可以进一步研究 Google Wave 的 OT 算法、Etherpad 的实现,或转向 CRDT(冲突无关复制数据类型)方案(如 Yjs),后者的实现逻辑更简单,对网络延迟的容忍度更高。