必须使用gorilla/websocket而非net/http标准库,因其封装了帧解析、心跳、并发安全写等协同编辑必需能力;net/http仅提供基础升级,手写易出错。

WebSocket连接必须用gorilla/websocket,别碰net/http自带的
Go标准库net/http只提供基础HTTP升级能力,不封装帧解析、心跳、重连或并发安全写操作——这些在协同编辑里全是硬需求。用它手写容易出write tcp: broken pipe或websocket: close sent错误,尤其在用户频繁切页、断网重连时。
gorilla/websocket已处理好:连接状态机、ping/pong自动应答、WriteMessage内部加锁、SetReadDeadline防阻塞读。Gin路由层只需做一次HTTP升级,后续全交给它:
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // 生产环境需校验Origin
}
func handleEditor(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// 后续用 conn.ReadMessage / conn.WriteMessage 操作
}
- 别在
Upgrade后还用c.Writer或c.Request.Body,连接已被接管 -
upgrader.CheckOrigin设为true仅限开发;生产必须白名单校验r.Header.Get("Origin") - 每个连接启动独立
readPump和writePump协程,避免读写互阻
前端发来的不是文本,是带version的op对象
如果前端直接发{"content":"hello world"},后端再广播给所有人,协作必崩——两人同时删同一段,谁后到谁覆盖。真正该收的是结构化操作,比如:
{"type":"insert","index":12,"text":"OK","version":42,"clientId":"u_abc123"}
服务端收到后不能直接存库或转发,必须走OT变换流程:
- 先查该文档当前
version,若msg.version <= doc.version,丢弃(过期操作) - 调用
ot.Transform(opA, opB)把新操作和文档最新状态对齐,得到可安全应用的transformedOp - 更新文档内容+版本号,再广播
transformedOp而非原始op
Java生态有ottypes或ShareDB,Go生态可用github.com/attic-labs/noms/go/types或轻量级github.com/sergi/go-diff辅助生成diff,但OT核心逻辑建议复用Node.js的ot-json0通过gRPC桥接,避免手写变换函数漏边角case。
光标和选区状态必须单独同步,不能靠内容推导
用户A在位置50插入字,B的光标还在位置45——内容变了,但B的光标不会自动跳。如果只同步文档字符串,B的光标会卡在错误位置,甚至触发二次错位插入。
正确做法是把UI状态拆成独立消息类型:
{"type":"cursor","pos":50,"clientId":"u_def456"}
- 这类消息不参与OT,不修改文档,只广播给除发送者外的所有人
- 前端收到后直接调用编辑器API(如CodeMirror 6的
view.dispatch({selection:{anchor:50}})) - 服务端不存储光标,只做透传;但需校验
clientId有效性,防伪造 - Presence状态(谁在线、正在编辑哪段)也走同一路由,用
map[string]time.Time内存缓存即可,不用落库
断线重连时pendingOps必须和服务端rebase结果合并
用户编辑中途断网,本地pendingOps队列里有3条未发出的insert/delete操作。重连后若直接发过去,服务端会按旧version拒绝——因为断线期间别人已改了文档。
标准流程是:
- 重连成功后,先发
{"type":"sync:pending","ops":[...],"docId":"d_789"}给服务端 - 服务端用当前权威状态对这批操作做rebase,返回
{"rebase":true,"ops":[{"type":"insert","index":55,...},...]} - 前端收到后,用新
index重新计算本地光标偏移,再逐条apply()
关键点:rebase不是简单加减偏移,而是完整OT变换。例如用户A删了[10,15],B在[8,12]插入,服务端必须把B的操作转换为index:13再返回——这个逻辑必须前后端一致,否则前端apply会错位。


















