基于 Operational Transformation 的多人实时文档协同编辑系统:多个客户端可同时编辑同一篇文档,编辑操作在服务端定序、变换后广播,各端最终收敛到一致文本,并实时同步彼此的光标。
- 经典 OT 内核:纯文本 insert/delete 的
Apply/Transform(TP1 收敛)/Compose/TransformPosition,Go 与 JS 双实现、语义对齐(UTF-16 计数)。 - 客户端三态状态机:ShareDB 式 pending/buffer 状态机(Synchronized / AwaitingConfirm / AwaitingWithBuffer),保证本地编辑与远端 ack 的正确合流。
- 多实例水平扩展:后端多实例用 etcd 协调,文档按需惰性认领属主(带 fence token 防脑裂)。单文档在其 owner 实例内以单 goroutine + channel 串行处理,天然满足 OT 全局有序。实例故障时属主锁经 etcd 租约释放,其他实例自动接管。
- 持久化与恢复:op 日志(append-only,真相源)+ 定期快照落 MySQL,实例重启或文档重新打开时自动恢复内存状态。
- 远端光标同步:实时广播协作者光标位置,并随文档编辑自动漂移。
- 用户与授权:注册/登录(bcrypt + Redis session),文档可按
single/subtree粒度授权给其他用户协同编辑。
后端为单 Go module otdemo,拆成三个可独立部署的进程,各自按 transport / service / infra 分层;跨服务共享收敛到极瘦的顶层 contracts/。
浏览器(多标签页)
web/ · ot.js / client.js / cursors.js
│ │
HTTP /api/* WebSocket /ws
│ │
▼ ▼
┌───────────────────────────────────────────────┐
│ edge-gateway :8081 无状态接入网关 │
│ 静态托管 / · /api/* 反代 · /ws 终止+扇出 │
└───────────────────────────────────────────────┘
│ 反代 │ authN/authZ │ Resolve 属主
│ │ Authenticate │ + Session 流
│ │ CanAccess │ (OT wire)
▼ ▼ ▼
┌────────────────────────────────┐ ┌──────────────────────────┐
│ app-service │ │ collab-engine :9082 gRPC│
│ :8090 HTTP / :9090 gRPC │ │ 有状态协同引擎 │
│ 无状态业务 │ │ │
│ ┌──────────────────────────┐ │ │ ┌─────────────────────┐ │
│ │ HTTP: auth/docs/grants │ │ │ │ CollabEngine gRPC │ │
│ │ AppAuthz gRPC: │ │ │ │ Session 双向流 │ │
│ │ CanAccess · Authenticate│ │ │ │ Resolve │ │
│ └──────────────────────────┘ │ │ └─────────┬───────────┘ │
└───────┬─────────────┬──────────┘ │ ┌─────────▼───────────┐ │
│ │ │ │ OT 内核·单文档串行定序│ │
│ │ │ └─────────┬───────────┘ │
│ │ └────────────┼──────────────┘
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
│ MySQL │ │ Redis │ │ MySQL │ │ etcd │
│ :3306 │ │ :6380 │ │ :3306 │ │ :2379 │
│ users/ │ │ session │ │ op 日志 │ │ 属主协调 │
│ docs/ │ │ │ │ + 快照 │ │ + fence │
│ grants │ │ │ │ │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────────┘
── 跨服务共享契约 contracts/(三个二进制编译期共用,非运行时节点)──
contracts/rpc : AppAuthz + CollabEngine 的 protobuf 生成物
contracts/wire : OT 线上消息结构 ClientMsg/ServerMsg/OpWithRev/CursorInfo
三个服务:
edge-gateway/(:8081,浏览器入口)— 无状态接入网关:静态托管 +/api/*反向代理 + WS 终止,向 collab-engine gRPC 扇出。app-service/(:8090 HTTP / :9090 gRPC)— 无状态业务服务:auth/docs/grants CRUD +AppAuthzgRPC(唯一鉴权决策点:CanAccess+Authenticate)。collab-engine/(:9082 gRPC)— 有状态协同引擎:OT 引擎 + etcd 属主协调 +CollabEnginegRPC(Session 双向流 + Resolve)。无对外 HTTP 口,只被 edge-gateway 内部调用。
顶层共享契约 contracts/(三个二进制都 import 的极瘦叶子,零业务逻辑):
contracts/rpc— protobuf 生成物:AppAuthz(CanAccess / Authenticate)与CollabEngine(Session 流 + Resolve)。contracts/wire— 客户端/服务端 OT 线上消息结构(ClientMsg/ServerMsg/OpWithRev/CursorInfo)。
各服务内部分层:
collab-engine/ot— OT 核心:Apply/Transform/Compose/TransformPosition+ JSON 线上编解码。collab-engine/route— 属主发现 + 惰性认领的Directory/Router。collab-engine/service— 单文档权威状态与串行处理循环(document)、本实例docId → *Document管理(hub)、Store端口。collab-engine/infra— 基础设施实现:store_mysql(op 日志 + 快照)、store_mem、etcd 协调器(coord,fence token)。collab-engine/transport—CollabEnginegRPC server +remote_client。app-service/service— 领域层:auth/docs/grants +Repo端口 + web 层共享上下文工具(webctx,session cookie 名等)。app-service/infra— 基础设施实现:docs/user/grants MySQL + session Redis。app-service/transport— HTTP handlers +RequireAuth中间件 +AppAuthzgRPC server。edge-gateway/transport— WS 桥接与 gRPC 扇出:wsproxy/pool/dial/engine_stream/authz_client/auth_client。web/— 原生 HTML/CSS/JS 前端:ot.js(客户端 OT)、client.js(三态状态机)、cursors.js(光标覆盖层)。
数据流:浏览器 WS → edge-gateway 终止并鉴权 → gRPC 扇出到 collab-engine 属主实例 → OT 串行定序 → 广播回各 gateway → 各客户端。属主解析走 engine 的 Resolve RPC,客户端不直接访问路由。
依赖 etcd(协调)、MySQL(持久化)、Redis(session),均由 docker compose 提供。一条命令起全栈(依赖容器 + 三进程,前台聚合日志,Ctrl-C 全清):
scripts/dev.sh # 起依赖容器 + 三进程
scripts/dev.sh --no-docker # 依赖已在跑时跳过 docker compose
scripts/dev.sh --build # 先编译成二进制再跑(避免 go run fork 子进程残留端口)浏览器打开 http://localhost:8081/?doc=demo,多开几个标签页即可协同。
也可手动分别起(需先 docker compose up -d 起依赖):
export OT_APP_GRPC=localhost:9090 OT_ENGINE_GRPC=localhost:9082
go run ./app-service --addr localhost:8090 &
go run ./collab-engine --addr localhost:9082 &
go run ./edge-gateway --addr localhost:8081 &docker compose up -d # 起 mysql:8.4 + redis:7 + etcd:v3.5- MySQL(:3306,库/账号/密码均为
otdemo)— op 日志与快照持久化。 - Redis(宿主 :6380 → 容器 6379,避让宿主 6379 上其他项目)— session 存储。
- etcd(:2379)— 属主协调与 fence token。
| 变量 | 默认值 | 说明 |
|---|---|---|
OT_MYSQL_DSN |
指向本地 compose 的 otdemo 库 | MySQL 连接串 |
OT_REDIS_ADDR |
127.0.0.1:6380 |
app-service 连接的 Redis 地址 |
OT_ETCD_ENDPOINTS |
本地 127.0.0.1:2379 |
collab-engine 的 etcd 端点 |
OT_APP_GRPC |
localhost:9090 |
app-service 的 gRPC 监听/被调地址 |
OT_ENGINE_GRPC |
localhost:9082 |
collab-engine 的 gRPC 地址 |
OT_APP_URL |
http://localhost:8090 |
gateway 反代 app-service 的 HTTP 地址 |
首次启动自动建表:doc_ops(append-only op 日志,真相源)+ doc_snapshots(每文档最新快照,每 100 条 op 打一次)。恢复时读最新快照并 replay 其后的 op 重建内存状态。处理顺序为「Apply 验证 → 同步写 op → 提交内存 → ack」,故 ack 代表已持久化。文档在无客户端连接时从内存淘汰,再次打开时从 MySQL 恢复。持久化由 collab-engine 承载。
需要覆盖连接串时设 OT_MYSQL_DSN:
export OT_MYSQL_DSN="user:pass@tcp(host:3306)/dbname?parseTime=true&multiStatements=true"注册/登录后才能进入文档。账号存 MySQL(users 表),session 存 Redis(HttpOnly cookie,7 天 TTL),密码 bcrypt 存哈希。session 由 app-service 持有;edge-gateway 不直连 Redis,/ws 的 authN(session cookie → uid)经 app-service 的 AppAuthz.Authenticate gRPC 完成。
接口:POST /api/register、POST /api/login、POST /api/logout、GET /api/me。/ws 与文档接口均需登录(RequireAuth 中间件从 cookie 还原 uid)。前端首屏访问 /api/me,401 自动跳 /login.html。
owner 可按用户名把文档授权给他人,scope 分 single(仅该文件)与 subtree(含全部子孙)。被授权者在「共享给我」区浏览并进入协同编辑。
接口:
GET /api/docs[?parent=<id>]— 懒加载按层拉取自有文档(省略parent拉根层)。GET /api/docs/{id}/grants、POST /api/docs/{id}/grants、DELETE /api/docs/{id}/grants/{uid}— owner 管理协作者名单。GET /api/shared[?parent=<id>]— 共享区:省略parent列授权根节点(带 owner 用户名),带parent展开该节点下当前用户可访问的子节点。
/ws join 受 CanAccess 保护:owner 或 grant 命中(single 仅授权点、subtree 含子孙)才放行,越权 join 返回 forbidden。
打开两个标签页 http://localhost:8081/?doc=demo,验证:
- 一个标签页打字,另一个实时出现相同文本。
- 两个标签页在不同位置同时快速打字,最终两边文本一致(收敛)。
- 一个标签页移动光标,另一个看到彩色远端光标 + 标签,且对方打字时光标位置跟随漂移。
- 关闭一个标签页,另一个的对应远端光标消失。
- 打开
?doc=alpha与?doc=beta,起多个 collab-engine 实例时,二者可能由不同实例认领属主(属主解析走 engine 的ResolveRPC)。
已有基于 Redis session + HttpOnly cookie 的登录鉴权(密码 bcrypt 存哈希)。当前传输未加密,WebSocket cookie 未加 Secure,且 CheckOrigin 恒为 true(允许任意来源)。对外部署前必须补齐传输加密(wss/https + cookie Secure)与 Origin 校验。