PHP项目中的操作变换(OT)与协同编辑
操作变换(Operational Transformation, OT)是实现实时协同编辑的核心技术,在PHP项目中实现OT需要特别注意PHP的语言特性(无状态、请求-响应模式等)。

OT基本原理
核心概念:
- 操作(Operation):对文档的修改(插入、删除、保留)
- 变换(Transformation):将操作A转换为能在操作B之后正确执行的版本
PHP实现OT的架构选择
基于WebSocket的实时方案
// server.php - 使用Ratchet WebSocket
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
class EditorServer implements MessageComponentInterface {
private $clients;
private $document;
private $operationQueue;
public function onMessage(ConnectionInterface $from, $msg) {
$data = json_decode($msg, true);
// 接收客户端操作
$operation = new Operation($data['type'], $data['position'], $data['text']);
// OT变换处理
$transformedOp = $this->transformOperation($operation, $from);
// 应用操作到文档
$this->document = $this->applyOperation($this->document, $transformedOp);
// 广播给其他客户端
foreach ($this->clients as $client) {
if ($client !== $from) {
$client->send(json_encode([
'operation' => $transformedOp,
'version' => $this->currentVersion++
]));
}
}
}
private function transformOperation($op, $from) {
// 获取该客户端的未应用操作列表
$pendingOps = $this->getPendingOps($from);
// 进行OT变换
foreach ($pendingOps as $pendingOp) {
$op = $this->OTTransform($op, $pendingOp);
}
return $op;
}
}
基于长轮询的HTTP方案
// 处理协同编辑请求
class CollaborationController {
public function applyOperation(Request $request) {
$operation = $request->get('operation');
$clientId = $request->get('client_id');
$lastVersion = $request->get('last_version');
// 获取服务器端操作历史
$serverOps = OperationHistory::getAfter($lastVersion);
// 执行OT变换
foreach ($serverOps as $serverOp) {
$operation = $this->OTTransform($operation, $serverOp);
}
// 应用变换后的操作
$this->document->apply($operation);
$this->operationHistory->add($operation);
return response()->json(['success' => true, 'version' => $this->document->getVersion()]);
}
}
完整的OT算法实现
class OperationalTransformation {
// 操作类型
const INSERT = 'insert';
const DELETE = 'delete';
const RETAIN = 'retain';
/**
* 转换两个并发操作
*/
public static function transform($op1, $op2) {
if ($op1->type === self::INSERT && $op2->type === self::INSERT) {
return self::transformInsertInsert($op1, $op2);
} elseif ($op1->type === self::INSERT && $op2->type === self::DELETE) {
return self::transformInsertDelete($op1, $op2);
} elseif ($op1->type === self::DELETE && $op2->type === self::INSERT) {
return self::transformDeleteInsert($op1, $op2);
} elseif ($op1->type === self::DELETE && $op2->type === self::DELETE) {
return self::transformDeleteDelete($op1, $op2);
}
}
private static function transformInsertInsert($op1, $op2) {
// 如果在同一位置插入,根据客户端ID决定顺序
if ($op1->position === $op2->position) {
// 使用客户端ID作为优先级
if ($op1->clientId < $op2->clientId) {
return $op1; // op1原样保留
} else {
// op1需要前移一位
$op1->position += 1;
return $op1;
}
}
// 如果op1位置在op2之前,不需要调整
if ($op1->position < $op2->position) {
return $op1;
}
// op1在op2之后,需要后移op1的插入位置
$op1->position += mb_strlen($op2->text);
return $op1;
}
private static function transformInsertDelete($op1, $op2) {
$op1Pos = $op1->position;
$op2Pos = $op2->position;
$op2Len = $op2->length;
if ($op1Pos <= $op2Pos) {
// 插入在删除之前,无需调整
return $op1;
} elseif ($op1Pos > $op2Pos + $op2Len) {
// 插入在删除之后,位置需要前移
$op1->position -= $op2Len;
return $op1;
} else {
// 插入在删除范围内
$op1->position = $op2Pos;
return $op1;
}
}
private static function transformDeleteInsert($op1, $op2) {
$op1Pos = $op1->position;
$op2Pos = $op2->position;
$op2Len = mb_strlen($op2->text);
if ($op1Pos < $op2Pos) {
return $op1; // 删除在插入之前
} else {
$op1->position += $op2Len; // 删除在插入之后
return $op1;
}
}
private static function transformDeleteDelete($op1, $op2) {
$op1Pos = $op1->position;
$op1Len = $op1->length;
$op2Pos = $op2->position;
$op2Len = $op2->length;
if ($op1Pos + $op1Len <= $op2Pos) {
// 完全在删除之前
return $op1;
} elseif ($op1Pos >= $op2Pos + $op2Len) {
// 完全在删除之后,位置前移
$op1->position -= $op2Len;
return $op1;
} elseif ($op1Pos >= $op2Pos && $op1Pos + $op1Len <= $op2Pos + $op2Len) {
// op1完全被op2覆盖
return null; // 操作取消
} elseif ($op1Pos < $op2Pos && $op1Pos + $op1Len > $op2Pos + $op2Len) {
// op1覆盖op2
$op1->length -= $op2Len;
return $op1;
} elseif ($op1Pos < $op2Pos) {
// 部分重叠,op1在前
$op1->length = $op2Pos - $op1Pos;
return $op1;
} else {
// 部分重叠,op1在后
$op1->position = $op2Pos;
$op1->length = ($op1Pos + $op1Len) - ($op2Pos + $op2Len);
return $op1;
}
}
}
// 操作类
class Operation {
public $type;
public $position;
public $text;
public $length;
public $clientId;
public $timestamp;
public function __construct($type, $position, $data = null, $clientId = null) {
$this->type = $type;
$this->position = $position;
$this->clientId = $clientId;
$this->timestamp = microtime(true);
if ($type === 'insert') {
$this->text = $data;
$this->length = mb_strlen($data);
} elseif ($type === 'delete') {
$this->length = $data;
}
}
}
存储与冲突解决
class DocumentManager {
public function saveOperation($docId, $operation) {
// 存储操作到数据库
DB::table('document_operations')->insert([
'document_id' => $docId,
'operation' => json_encode($operation),
'version' => $operation->version,
'created_at' => now()
]);
}
public function getOperationsAfter($docId, $version) {
return DB::table('document_operations')
->where('document_id', $docId)
->where('version', '>', $version)
->orderBy('version')
->get()
->map(function($row) {
return json_decode($row->operation);
});
}
public function resolveConflicts($docId, $clientOps, $serverVersion) {
// 获取服务器端从客户端最后版本开始的所有操作
$serverOps = $this->getOperationsAfter($docId, $serverVersion);
// 对每个客户端操作进行OT变换
$transformedOps = [];
foreach ($clientOps as $clientOp) {
$transformedOp = clone $clientOp;
foreach ($serverOps as $serverOp) {
$transformedOp = OperationalTransformation::transform($transformedOp, $serverOp);
if ($transformedOp === null) {
break; // 操作已被取消
}
}
if ($transformedOp !== null) {
$transformedOps[] = $transformedOp;
}
}
return $transformedOps;
}
}
实际项目中的优化建议
使用Redis加速操作队列
class RedisOperationQueue {
private $redis;
public function addOperation($docId, $operation) {
$key = "doc:{$docId}:ops";
$this->redis->rPush($key, json_encode($operation));
$this->redis->expire($key, 3600); // 1小时后过期
}
public function getOperations($docId, $sinceVersion) {
$key = "doc:{$docId}:ops";
$ops = $this->redis->lRange($key, $sinceVersion, -1);
return array_map('json_decode', $ops);
}
}
性能优化策略
- 批量处理:合并多个小操作为一个大操作
- 增量同步:只传输差异部分,而非完整文档
- 压缩传输:使用gzip压缩JSON数据
- 客户端缓存:在浏览器端缓存文档快照
现成的PHP协同编辑解决方案
- TinyMCE + PHP后端:使用TinyMCE的协同编辑插件
- CKEditor + PHP:通过CKEditor的实时协作功能
- Google Docs API:直接使用Google的协同编辑能力
- Etherpad Lite:PHP可与其API集成
注意事项
- PHP性能限制:对于高并发场景,考虑使用C扩展或Swoole
- 数据一致性:确保操作顺序正确,使用事务保证原子性
- 版本控制:维护明确的操作版本号,防止冲突
- 错误处理:实现重试机制和冲突恢复策略
PHP实现OT协同编辑的核心挑战在于:
- 无状态到有状态的转换
- 实时性要求与HTTP请求-响应模式的矛盾
- 操作顺序的严格保证
建议使用WebSocket(如Ratchet、Swoole)配合Redis,构建高效可靠的协同编辑系统,对于简单场景,也可以采用HTTP长轮询方案,但会有较高的延迟和资源消耗。