diff --git a/src/ot/client.ts b/src/ot/client.ts index def3135..ef03979 100644 --- a/src/ot/client.ts +++ b/src/ot/client.ts @@ -9,8 +9,6 @@ export class Client { public name: string = ""; // 当前文档 public document: string = ""; - // 当前服务端文档状态 - public syncedDoc: string = ""; // 下一个期望的修订号 public revision: number; // 正在处理的操作,已经发送给服务端等待确认 @@ -35,64 +33,48 @@ export class Client { this.sendOperationIfNeed(); } - // 接收到来自服务器的新操作,可能是另一个客户端的操作,也可能是服务端确认本地操作的响应 - applyServer(revision: number, operation: TextOperation): void { - if (revision < 0 || this.revision > revision) { - throw new Error("operation revision not in history"); - } - if (revision < this.revision) { + // 接收到其它客户端的操作 + applyServer(operation: TextOperation): void { + if (!this.enableSync) { + // 模拟离线,忽略其它客户端的操作 return; } - - const isSelfOp = operation.id === this.outstanding?.id; - if (this.outstanding) { - // 将outstanding操作转化为执行operation后的操作 - const newOp = TextOperation.transform(this.outstanding, operation); - newOp.id = this.outstanding.id; - this.outstanding = newOp; + // 将operation转换为基于outstanding操作后的操作 + operation = TextOperation.transform(this.outstanding, operation)[1]; } + // 现在,operation是基于outstanding操作后的操作,buffer[0]也是 + // (如果outstanding为空,则都是基于server文档状态操作) for (let i = 0; i < this.buffer.length; i++) { - // 将buffer中的操作转换为基于服务端文档状态的操作 - // FIXME: 对buffer中的操作应该合并后再transform - const newOp = TextOperation.transform( - this.buffer[i], - // 如果有outstanding,buffer[0]应该基于outstanding - this.buffer[i - 1] || this.outstanding || operation - ); - newOp.id = this.buffer[i].id; - this.buffer[i] = newOp; + // 将operation转换基于buffer[i]操作后的操作 + operation = TextOperation.transform(this.buffer[i], operation)[1]; } - // 本地重新应用buffer中的操作,合并其它客户端的操作到本地 - let newDoc = this.syncedDoc; - newDoc = operation.apply(newDoc); - for (let i = 0; i < this.buffer.length; i++) { - newDoc = this.buffer[i].apply(newDoc); - } - this.document = newDoc; + console.log("client", this.name, "received operation", operation); - // 更新本地同步状态 - this.syncedDoc = operation.apply(this.syncedDoc); - this.revision = revision; - - // 操作来自服务端确认本地操作的响应 - if (isSelfOp) { - this.serverAck(); - } - console.log("mattuy", "client", this.name, this); + // 现在operation是基于本地最新document的操作了,直接在本地应用它! + this.applyOperation(operation); + this.revision++; } - // 服务端已确认收到发送的操作,从buffer中移除该操作,尝试发送下一个 + // 本地应用操作 + applyOperation(operation: TextOperation): void { + this.document = operation.apply(this.document); + } + + // 服务端已确认收到发送的操作,尝试发送下一个操作 + // 简单实现。即使是模拟离线状态我们也接收Ack,避免实现操作去重 serverAck(): void { + console.log("client", this.name, "operation ack", this.outstanding); this.outstanding = null; + this.revision++; this.sendOperationIfNeed(); } // 逐个向服务端发送本地待提交的操作。发送后等待服务端确认,触发serverAck方法后继续发送下一个操作 async sendOperationIfNeed() { - // 如果禁用了同步,则不向服务端发送操作 + // 模拟离线,不向服务端发送操作 if (!this.enableSync) { return; } @@ -102,16 +84,17 @@ export class Client { } // 向服务端发送本地待提交的操作,这里没有实现操作压缩,我们一个一个发送 this.outstanding = this.buffer.shift()!; - // 模拟向服务端发送操作,实际实现应该通过网络 + // 模拟向服务端发送操作,实际实现应该通过网络发送 server.receiveOperation(this, this.revision, this.outstanding); - // 应该有超时重发机制,这里没有实现 } - // 设置是否启用同步到服务端 + // 设置模拟离线状态 setEnableSync(enabled: boolean): void { + console.log("client", this.name, enabled ? "online" : "offline"); this.enableSync = enabled; - // 如果重新启用同步,尝试发送待发送的操作 + // 重新启用同步,继续发送本地操作,发送一个空操作,确保服务端向我们推送最新操作 if (enabled) { + // this.buffer.push(new Noop()); this.sendOperationIfNeed(); } } diff --git a/src/ot/op.ts b/src/ot/op.ts index 7c55a45..9ef4c9e 100644 --- a/src/ot/op.ts +++ b/src/ot/op.ts @@ -1,93 +1,143 @@ // 简单文本操作的实现 -import { v4 as uuidv4 } from "uuid"; export abstract class SimpleTextOperation { - public id: string = uuidv4(); abstract toString(): string; abstract equals(other: SimpleTextOperation): boolean; abstract apply(doc: string): string; - // 把操作a转换为基于应用操作b后的文档状态的操作,返回操作a' - static transform(a: SimpleTextOperation, b: SimpleTextOperation): SimpleTextOperation { + /** + * OT算法转换函数 + * @param a 操作a + * @param b 操作b + * @returns + * [0] = a' 操作a基于操作b应用后的操作 + * [1] = b' 操作b基于操作a应用后的操作 + * apply(apply(S, a), b') = apply(apply(S, b), a') + */ + static transform( + a: SimpleTextOperation, + b: SimpleTextOperation + ): [SimpleTextOperation, SimpleTextOperation] { if (a instanceof Noop || b instanceof Noop) { - return a; + return [a, b]; } + // 情况2: a 和 b 都是 Insert(插入操作) if (a instanceof Insert && b instanceof Insert) { - if (a.position < b.position) { - // b在a插入的地方之后插入,不转换 - return a; + // 情况2.1: a 的插入位置在 b 之前,或者位置相同但 a 的字符串字典序更小 + // 处理: a 保持不变,b 的插入位置需要向后偏移 a 插入的字符串长度 + if (a.position < b.position || (a.position === b.position && a.str < b.str)) { + return [a, new Insert(b.str, b.position + a.str.length)]; } - // b在a插入的地方之前插入,位置偏移b插入的str长度后插入 - return new Insert(a.str, a.position + b.str.length); + // 情况2.2: a 的插入位置在 b 之后,或者位置相同但 a 的字符串字典序更大 + // 处理: a 的插入位置需要向后偏移 b 插入的字符串长度,b 保持不变 + if (a.position > b.position || (a.position === b.position && a.str > b.str)) { + return [new Insert(a.str, a.position + b.str.length), b]; + } + // 情况2.3: a 和 b 插入位置相同且字符串完全相同 + // 处理: 两个操作都变为 noop,因为插入的内容相同 + return [noop, noop]; } + // 情况3: a 是 Insert(插入),b 是 Delete(删除) if (a instanceof Insert && b instanceof Delete) { + // 情况3.1: a 的插入位置在 b 的删除位置之前或相同 + // 处理: a 保持不变,b 的删除位置需要向后偏移 a 插入的字符串长度 if (a.position <= b.position) { - // b在a插入的地方之后删除,不转换 - return a; + return [a, new Delete(b.count, b.position + a.str.length)]; } + // 情况3.2: a 的插入位置在 b 的删除范围之后 + // 处理: a 的插入位置需要向前偏移 b 删除的字符数,b 保持不变 if (a.position >= b.position + b.count) { - // b在a插入的位置之前删除,并且a插入位置的字符没有被删除 - return new Insert(a.str, a.position - b.count); + return [new Insert(a.str, a.position - b.count), b]; } - // b在a插入的位置之前删除,并且a插入位置的字符被删除,在b删除的位置插入a的str - return new Insert(a.str, b.position); + // 情况3.3: a 的插入位置在 b 的删除范围内部(有冲突) + // 处理: a 变为 noop(插入的内容被删除),b 的删除范围需要包含 a 插入的字符串长度 + // 注意: 这会丢失 a 的插入意图,但这是保持转换函数有效性的唯一方法 + return [noop, new Delete(b.count + a.str.length, b.position)]; } + // 情况4: a 是 Delete(删除),b 是 Insert(插入) if (a instanceof Delete && b instanceof Insert) { + // 情况4.1: a 的删除位置在 b 的插入位置之后或相同 + // 处理: a 的删除位置需要向后偏移 b 插入的字符串长度,b 保持不变 if (a.position >= b.position) { - // a在插入位置之后删除,增加删除操作的偏移 - return new Delete(a.count, a.position + b.str.length); + return [new Delete(a.count, a.position + b.str.length), b]; } + // 情况4.2: a 的删除范围在 b 的插入位置之前(完全不相交) + // 处理: a 保持不变,b 的插入位置需要向前偏移 a 删除的字符数 if (a.position + a.count <= b.position) { - // a在插入位置之前删除,并且b插入之前的字符没有被删除,不转换 - return a; + return [a, new Insert(b.str, b.position - a.count)]; } - // b插入的内容在a删除的范围内,直接把b插入的内容一起删除 - // NOTICE: 这里b插入的内容丢失了,在生产环境应该裂变为前后两个删除操作,这里简化了实现 - return new Delete(a.count + b.str.length, a.position); + // 情况4.3: b 的插入位置在 a 的删除范围内部(有冲突) + // 处理: a 的删除范围需要包含 b 插入的字符串长度,b 变为 noop(插入的内容被删除) + // 注意: 这会丢失 b 的插入意图,但这是保持转换函数有效性的唯一方法 + return [new Delete(a.count + b.str.length, a.position), noop]; } + // 情况5: a 和 b 都是 Delete(删除操作) if (a instanceof Delete && b instanceof Delete) { - // 删除位置相同 + // 情况5.1: a 和 b 的删除位置相同 if (a.position === b.position) { - if (a.count <= b.count) { - // b删除得更多,已经把a要删除的删掉了,no-op - return noop; + // 情况5.1.1: 删除的字符数相同 + // 处理: 两个操作都变为 noop,因为删除的内容完全相同 + if (a.count === b.count) { + return [noop, noop]; } - // b删除得少,a删除得多,删除b没删掉的部分 - return new Delete(a.count - b.count, a.position); + // 情况5.1.2: a 删除的字符数少于 b + // 处理: a 变为 noop(已经被 b 删除),b 只删除剩余的部分 + else if (a.count < b.count) { + return [noop, new Delete(b.count - a.count, b.position)]; + } + // 情况5.1.3: a 删除的字符数多于 b + // 处理: a 只删除剩余的部分,b 变为 noop(已经被 a 删除) + return [new Delete(a.count - b.count, a.position), noop]; } + // 情况5.2: a 的删除位置在 b 之前 if (a.position < b.position) { + // 情况5.2.1: a 的删除范围完全在 b 的删除位置之前(不相交) + // 处理: a 保持不变,b 的删除位置需要向前偏移 a 删除的字符数 if (a.position + a.count <= b.position) { - // a在b删除的位置之前删除,并且a删除的结束位置在b删除的开始位置之前,不转换 - return a; + return [a, new Delete(b.count, b.position - a.count)]; } + // 情况5.2.2: a 的删除范围完全包含 b 的删除范围 + // 处理: a 只删除 b 未删除的部分,b 变为 noop(已经被 a 删除) if (a.position + a.count >= b.position + b.count) { - // a删除的范围包含b删除范围,减去b已经删除的长度 - return new Delete(a.count - b.count, a.position); + return [new Delete(a.count - b.count, a.position), noop]; } - // a删除的范围在b删除的范围之前且有交集,减去交集部分长度 - // b: *****------**** - // a: ***----******** - // a': ***--********** - return new Delete(b.position - a.position, a.position); + // 情况5.2.3: a 和 b 的删除范围有交集,a 在前,b 在后 + // 处理: a 只删除到 b 开始位置之前的部分,b 删除从 a 结束位置之后到 b 结束位置的部分 + // 示例: 文档 "0123456789" + // a: 删除位置0-5 (删除 "01234") + // b: 删除位置3-7 (删除 "3456") + // 结果: a' 删除位置0-3 (删除 "012"),b' 删除位置3-5 (删除 "67") + return [ + new Delete(b.position - a.position, a.position), + new Delete(b.position + b.count - (a.position + a.count), a.position), + ]; } + // 情况5.3: a 的删除位置在 b 之后 if (a.position > b.position) { + // 情况5.3.1: a 的删除范围完全在 b 的删除范围之后(不相交) + // 处理: a 的删除位置需要向前偏移 b 删除的字符数,b 保持不变 if (a.position >= b.position + b.count) { - // a删除的范围在b之后且没有交集,删除位置减去b删除的长度 - return new Delete(a.count, a.position - b.count); + return [new Delete(a.count, a.position - b.count), b]; } + // 情况5.3.2: b 的删除范围完全包含 a 的删除范围 + // 处理: a 变为 noop(已经被 b 删除),b 只删除剩余的部分 if (a.position + a.count <= b.position + b.count) { - // b删除范围包含a删除范围,no-op - return noop; + return [noop, new Delete(b.count - a.count, b.position)]; } - // a删除的范围在b删除的范围之后且有交集,减去交集部分长度,并把开始位置放到交集结束位置 - // b: ***----********* - // a: *****------***** - // a': *******----***** - return new Delete(a.count - b.count, b.position + b.count); + // 情况5.3.3: a 和 b 的删除范围有交集,b 在前,a 在后 + // 处理: a 删除从 b 结束位置之后到 a 结束位置的部分,b 删除从 b 开始位置到 a 开始位置的部分 + // 示例: 文档 "0123456789" + // b: 删除位置3-7 (删除 "3456") + // a: 删除位置5-9 (删除 "5678") + // 结果: a' 删除位置7-9 (删除 "89"),b' 删除位置3-5 (删除 "34") + return [ + new Delete(a.position + a.count - (b.position + b.count), b.position), + new Delete(a.position - b.position, b.position), + ]; } } diff --git a/src/ot/server.ts b/src/ot/server.ts index cbafc6e..300e8e1 100644 --- a/src/ot/server.ts +++ b/src/ot/server.ts @@ -16,25 +16,24 @@ class Server { } // 每当从客户端接收到操作时调用此方法 - async receiveOperation(client: Client, revision: number, operation: TextOperation) { + receiveOperation(client: Client, revision: number, operation: TextOperation) { + console.log("server", "received operation", revision, operation); + if (revision < 0 || this.operations.length < revision) { throw new Error("operation revision not in history"); } - console.log("mattuy", "received operation", revision, operation); // 查找客户端发送操作时不知道的所有操作... const concurrentOperations = this.operations.slice(revision); // ...并将操作与所有这些操作进行转换... - const opId = operation.id; const transform = TextOperation.transform; for (let i = 0; i < concurrentOperations.length; i++) { // op' = transform(op, op1) // op基于 revision+i 版本的文档操作,op'基于 revision+i+1 版本的文档操作 - operation = transform(operation, concurrentOperations[i]); + operation = transform(operation, concurrentOperations[i])[0]; // 向客户端发送其它客户端的操作 - client.applyServer(revision + i + 1, concurrentOperations[i]); + client.applyServer(concurrentOperations[i]); } - operation.id = opId; // ...并将其应用到文档上 this.document = operation.apply(this.document); @@ -42,9 +41,16 @@ class Server { this.operations.push(operation); // 将操作发送给所有连接的客户端并向创建者发送确认 - this.clients.forEach((client) => { - client.applyServer(this.operations.length, operation); + this.clients.forEach((otherClient) => { + if (otherClient === client) { + // 自己不需要接收自己的操作,会响应Ack + return; + } + otherClient.applyServer(operation); }); + + // 客户端确认收到操作 + client.serverAck(); } // 模拟客户端连接