资讯动态

GoWind CMS 拆解:在 Go 里给 TipTap 搭一套 Yjs 协同后端

发布时间:2026/10/26 18:16:09 来源:尧图企业网站定制
GoWind CMS 拆解在 Go 里给 TipTap 搭一套 Yjs 协同后端本文回答一个问题两个人同时编辑同一篇富文本从浏览器到 Go 服务端再到数据库中间要处理哪些协议、锁、持久化与竞态GoWind CMS 的帖子/页面编辑页支持多人同编在线光标、无冲突合并、断线恢复、离线内容回流。后端没有用任何现成的协同 SaaS而是在 Go 侧搭了一套 Yjs 兼容的 WebSocket 服务COLLAB_ADDR默认:6602与 TipTap 的 Collaboration 扩展直连。这个功能的实现从 v1 到 v2.5 演进了三版编辑锁 → 持久化自愈 → 服务端权威种子化每一版解决一类问题踩过的坑都留在了代码注释里——这篇按演进顺序拆。一、选型为什么不自己写 CRDT协同编辑的核心是 CRDT无冲突复制数据类型每个编辑操作都是可交换、可交换重放的增量多端乱序应用最终收敛到同一状态。自己实现 CRDT 是数月级的算法工程GoWind CMS 的选择是站在现成引擎上客户端Yjsyjs ^13.6 TipTaptiptap/extension-collaboration——前端生态的事实标准服务端ygo——reearth 开源的纯 Go Yjs 兼容 CRDT 引擎go.mod引入v1.51.11并 replace 到自己的 forkreplace github.com/reearth/ygo github.com/tx7do/ygo集成层需要的小改动走 fork 而不是绕路。ygo 提供两块能力crdt包Doc/Transaction/YXmlFragment/YXmlText/Delta——Yjs 文档模型的 Go 侧实现和provider/websocket包y-websocket 协议的服务端实现带AuthFunc、AllowedOrigins、OnLoadDocument、CloseRoom等钩子。协议层零自研浏览器与 Go 服务端之间跑的就是原生 y-websocket 二进制帧前端的y-websocket客户端不需要任何适配。这个选型决定了后端要做的全部事情是四件工程问题而非算法问题准入与房间管理第二节、并发编辑的感知与提示第三节、断线不丢第四、五节、生命周期竞态第六节。二、房间与准入一个独立端口的协同面协同 ws 服务以 kratostransport.Server的形态挂在 admin 服务进程内、独立监听:6602——与 REST/gRPC/SSE 面隔离COLLAB_ADDRoff可整体停用停用时 ws 面为 nil但删除内容时的踢房调用仍安全 no-op。房间模型房间名取 ws URL 路径末段格式post:{id}:{locale}/page:{id}:{locale}——按内容 语言双维隔离同一篇文章的中文稿和英文稿是两个房间各自独立的 CRDT 状态。token 准入是第一个绕不开的坑浏览器 WebSocket API 无法携带Authorization头token 只能走 query 参数。这与运维平台 Web 终端的解法同源——把准入门槛做成与 SSE 同级校验 access token 有效性内容级的细粒度权限交给保存链路兜底协同编辑的每一次保存仍走 core 的UpdateRBAC 在那里生效func(s*Service)authenticate(r*http.Request)bool{token:r.URL.Query().Get(token)...resp,err:s.authClient.ValidateToken(ctx,authenticationV1.ValidateTokenRequest{ClientType:authenticationV1.ClientType_admin,Token:token,TokenCategory:authenticationV1.TokenCategory_ACCESS,})returnresp.GetIsValid()}Origin 白名单COLLAB_ALLOWED_ORIGINS逗号分隔挡住跨站 ws 握手——浏览器发起的 ws 会带 Origin 头这是 ws 场景下比 CSRF token 更直接的防线。房间生命周期由 ygo 的OnLoadDocument/OnUnloadDocument钩子驱动服务侧维护liveRooms表支撑按前缀踢房CloseRoomsByPrefix第六节的删除联动会用到。ygo 的一个行为需要知道evict 房间前会 flush 待写状态——这个特性是第六节竞态的另一半。三、v1 编辑锁只感知不拦截协同上线的第一版其实不是 CRDT是一把软锁。动机很朴素CRDT 接入前多人同编一篇帖子是后保存覆盖先保存第一版的诉求只是让编辑者知道有人在编辑。实现是 Redis 软锁edit_lock_service.go两段 Lua 脚本保证原子性获取/心跳本人持锁 →SET ... EX 120续期获取与心跳是同一个调用他人持锁 → 原样返回锁值供前端展示谁在编辑空闲 → 上锁。释放仅持锁者本人比对锁值里的 user_id可 DEL——防止误删别人的锁。TTL 120 秒是前端心跳间隔60 秒的两倍余量——进程崩溃后锁自动过期释放不需要任何看门狗。锁值是 JSONUserID/UserName/AcquiredAtkey 按editlock:{type}:{id}隔离。设计上最值得记录的是这条边界注释「只做编辑感知与冲突提示不做强制写拦截保存冲突由内容版本化兜底」。锁的语义止步于提示横幅他人持锁时你仍可编辑保存真冲突了由内容版本化的快照链路兜底回溯。把锁做成强互斥很容易把锁做成不强制的感知才是设计——强互斥要求处理锁泄漏、强制接管、死锁恢复一整套问题而感知锁的失败模式就是提示消失了无害。四、v2 持久化与自愈CRDT 状态的续命存储CRDT 协同的默认形态是纯内存房间无人即销毁服务重启全员状态归零——重启/全员断线丢最后几秒输入的窗口对编辑器场景不可接受。v2 加了持久化层架构受两服务拓扑约束ygo PersistenceAdapter (admin) → gRPC LoadDoc / StoreDocState (5s 超时) → core CollabDocStateService → ent → collab_doc_states 表 (room 唯一索引 doc_update bytes)admin 不触库是这个代码库的既定分层所以持久化走 gRPC 绕道 core。表上的注释值得整段引用它定义了这张表的准确身份服务重启后据此恢复协同会话消除『重启/全员断线丢最后几秒输入』的窗口。内容事实源仍是 core 内容表本表只是协同会话的续命存储。续命存储的定位决定了它的写路径可以激进ygo 侧已对 update 做防抖合并StoreUpdate收到的是合并后的整量状态仓储层就是 query-then-upsert 整量替换——不存在增量合并的复杂度。客户端全量上报回流是自愈的另一半而且是个协议层的暗坑ygo 服务端不会主动向客户端索要状态没有服务端发起的 SyncStep1所以服务端有恢复态、客户端本地有 IndexedDB 离线编辑时两边的合并必须由客户端主动上报触发。前端TiptapEditor的sendFullState手写了帧头// messageSync(0) SyncStep2(1) varuint 长度 Y.encodeStateAsUpdate(ydoc)能这么干依赖 CRDT 的数学性质update 幂等且可交换重复上传无害——这也是整条自愈链路敢做成 best-effort 的底气。客户端同步完成后全量上报本地状态服务端状态被旧内容覆盖的场景也能自愈。配套还有一个种子化持久锁localStorage 键gwc-collab-seeded:{room}服务端已有持久化协同状态后任何后续客户端清缓存重开、admin 重发版不得再用 core 里的旧内容覆盖恢复态。这个锁在 v2.5 服务端权威种子化落地后理论上是冗余的但注释明说保留作历史客户端防御——新旧版本客户端混跑的过渡期防御性冗余比干净重要。五、v2.5 服务端权威种子化HTML→Yjs 的受控转换v2 解决了状态不丢还剩一个入口问题帖子已有内容core 里存的是 HTML第一个人进协同房间时内容怎么进 CRDT 文档v2.5 的答案是服务端权威种子化ygo 的OnLoadDocument钩子每房间仅触发一次里若恢复后文档为空从 core 拉取内容做一次HTML → Yjs 文档树的受控转换。触发条件收敛得很严仅post房间page 是 sections 结构化数据无单一 HTML 正文不种子化、仅当XmlFragment(default)为空。转换器分两层seed.go块级 seed_inline.go行内最有价值的部分是它的分寸感——支持什么、降级什么都有明确清单层级支持降级策略块级p、h1-h6、bulletList/orderedList含嵌套 listItem、blockquote、codeBlock解析language-x、hrblockquote 内部块降级为段落table 不支持降级为逐行段落文本未知块级标签div/section递归子节点行内strong/b→bold、em/i→italic、u→underline、s/del→strike、code→code、a→link、br→hardBreak、img→image相邻同 marks 文本合并为一次 delta显式空 Attributes 阻断格式继承降级是刻意的转换器的职责是把既有内容完整带进房间不是完美重建编辑器 schema——表格降级成文本段落用户看到的仍是全部内容协同保存后再由编辑器重新结构化。受控转换的对立面是天真转换把 HTML 塞给解析器 whatever it produces后者的输出一旦混进 TipTap 不认识的节点整个房间的 CRDT 状态都会被污染。双编辑器分流也在这一层Markdown 编辑器种子化为YText(content)yCollab 绑定文本富文本种子化为XmlFragment(default)TipTap Collaboration 的字段名——同一个房间机制两种文档形态。两条铁律直接引自seed.go的注释它们是 ygo 使用中真实撞出来的墙// ygo 铁律Transact 内调用 doc.GetXmlFragment 等读方法会死锁——// 引用必须在事务外取好// 逐子节点独立事务前向追加同一事务内 prelim 混合子节点//YText 与 YXmlElement 混排重放会乱序独立事务天然按序第一条是 API 陷阱事务重入死锁第二条是 CRDT 语义细节prelim 节点在同一事务内的混排顺序问题——解决方式是每个子节点一个独立事务、前向追加用事务次数换确定性种子化是一次性操作这个代价完全付得起。这类注释就是为什么不自己写 CRDT 但仍需要懂 CRDT的最好注脚。六、尾部硬仗删除内容时的三段竞态内容被删除时协同房间必须一并清理但直接删会撞上三个环节的竞态踢房 → ygo flush → 删持久化行。时序展开CloseRoomsByPrefix(post:{id}:)force 断开所有连接——但 ygo 在 evict 前会flush 待写状态这次 flush 可能是一次新的StoreUpdate若先删了collab_doc_states行、flush 后写回来——被删房间的状态复活若不删行——孤儿状态永久残留。实现post_service.go用延时窗口 两段删除处理CloseRoomsByPrefix(post:{id}:)// ① force 踢房(触发 flush)time.Sleep(1500*time.Millisecond)// ② 预留 flush worker 窗口DeleteDocStatesByPrefix(...)// ③ 立即删一次time.AfterFunc(3*time.Second,func(){// ④ 兜底再删一次DeleteDocStatesByPrefix(...)})第二段兜底删防的是flush 恰好落在第一段删除之后的窄窗口。两段删除各自幂等竞态的所有交错结果都收敛到行被删掉且不再复活。sleep 1500ms是经验值而非精确推算——注释没有伪装它是精确的这种留余量的工程近似在异步清理场景里比追求精确可靠得多。七、前端侧三件熔断、bundle 缓存与在线感知鉴权熔断TiptapEditor.tsxws 连接的close/error事件里若从未同步成功过everSynced false则计数连续 3 次失败 →provider.disconnect()停用重连一旦同步成功过则永不熔断。为什么区分同步过没有token 过期/权限被收走时 ws 握手会无限 401 退避循环重连毫无意义而网络抖动的重连是真有价值的——用是否成功过区分这两种失败是 WebSocket 重连策略里少有人写对的判据。bundle 缓存React 18 的 StrictMode 会在开发期挂载-卸载-重挂载组件yjs 的 doc/provider/indexeddb 三件套若随组件销毁重建CRDT 状态会被反复初始化。解法是模块级Maproom, bundle缓存注释写明取舍“bundle 只创建不销毁随页面生命周期结束房间数有限内存可控”——对象图的生命周期跟页面走而不是跟组件走是所有重初始化代价高的第三方对象在 React 里的通用解法。在线感知TipTapCollaborationCaret显示协作光标协作者颜色由CURSOR_PALETTE[userId % 8]按用户 id 稳定取色同人恒同色编辑页顶栏的头像组监听 yjs awareness 状态渲染在线名单。awarness 通道临时状态与文档 update 通道持久状态在 yjs 协议里天然分离光标不需要任何持久化。保存链路完全解耦是收尾的关键约束协同模式下编辑器的valueprop 只作种子“外部回写会与 ydoc 打架”显式保存仍收集当前内容走PostService.Update——版本化快照、Webhook 事件、ES/AI 索引双写钩子一个都不变。协同编辑是编辑器输入层的技术不是内容管线的分支yjs 自带的 undo 与 TipTap 内置 undoRedo 互斥协同分支显式关闭后者撤销栈也因此不分裂。八、结语回头看这套东西的形状CRDT 算法是别人的ygo/Yjs协议是标准的y-websocketGoWind CMS 真正写的部分全部是边界上的工程——准入query token Origin、感知不拦截的软锁、持久续命存储 客户端回流 种子锁、入口受控种子化、清理三段竞态、熔断按是否同步过分流。这大概就是在 Go 里给前端生态拼一个实时协同后端的真实工作量分布没有一行 CRDT 算法但每个边界都有一段注释在讲它防住了什么。如果只带走三句话CRDT 永远不要自己写但接入它需要的 CRDT 语义知识prelim 混排、update 幂等一条都省不掉编辑锁做成感知而不是互斥——冲突兜底交给版本化锁的失败模式就无害化了协同状态与内容事实源分离——续命存储的定位让持久化可以激进简化整量替换删除联动只需处理自己的竞态。仓库协同编辑在backend/app/admin/service/internal/collab与frontend/admin/src/components/common/EditorGitHubgithub.com/tx7do/go-wind-cmsGiteegitee.com/tx7do/go-wind-cms

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑