本地 Markdown 如何接入云端协同?Notedeep 的两层同步设计
{}
本地 Markdown 如何接入云端协同?Notedeep 的两层同步设计
本地文件允许用户和工具直接修改,云端协同却需要有序的操作记录。我在 Notedeep 中用两层状态连接这两种需求。
背景与目标
把笔记保存成 Markdown,有一个很实际的好处:用户可以用熟悉的编辑器修改,脚本和 AI 工具也能直接处理文件。
但文件可编辑,并不等于它天然适合云端协同。假设本地改了一个段落,另一台设备也改了云端页面。如果恢复连接时直接上传本地全文,系统必须决定整份内容以谁为准;单看文件修改时间,也解释不了两次修改之间的关系。
我希望本地保存不依赖网络,同时让这些改动能进入云端已有的协同链路。Notedeep Desktop 的做法是保留 Markdown 作为本地正文事实,再增加一层结构化同步状态。云端页面使用 ShareDB,提交的是 JSON0 操作,而不是把 Markdown 文本直接覆盖过去。
核心架构
这两层分别回答不同问题。
第一层回答“我现在的笔记是什么”。正文在 Markdown 文件里,页面身份、路径和顺序等页面树信息在 manifest 中。用户和外部工具接触的是这层开放数据。
第二层回答“这些变化同步到了哪里”。每个文档有 LocalDocState,保存结构化快照 snapshot、待提交队列 pendingOps,以及云端版本基线。正文快照使用 JsonML,可以理解为页面块结构的数组表示;页面树则有独立的结构化文档。
snapshot 不是另一个独立编辑入口。它是本地变化进入同步队列后的结构化状态;后续扫描需要用它和当前文件转换结果比较,找出新的变化。
还有一份扫描索引,记录文件大小、修改时间和内容摘要,用来跳过未变化的页面。这是加速层,不应和承载待提交操作的同步状态混为一谈。
关键实现
整个过程分为 stage 和 flush:前者把本地变化纳入队列,后者尝试与云端对齐。
扫描发现页面可能变化时,先读取 Markdown。转换入口复用编辑器的 Markdown 导入能力,生成 block DOM,再得到 JsonML。随后比较新旧结构,生成 JSON0 操作,追加到 pendingOps,递增本地操作序号,更新快照并持久化。
这一步与是否能连上云端分开。当前同步入口会先整理本地页面和状态,再检查登录情况:未登录时返回暂停状态;账号与知识库绑定不一致时返回阻塞状态。这里的“暂停同步”不等于“拒绝保存本地文件”。
flush 阶段读取远端版本。如果云端已经领先,就从本地记录的云端基线开始拉取遗漏操作,把本地待提交操作按远端变化做 transform,然后再提交。服务端确认后,操作从 pendingOps 移入 appliedOps,并写回状态。
下面沿用设计中的版本时序,数字只是便于理解的例子,不是运行测量值:
sequenceDiagram
participant F as 本地文件
participant State as LocalDocState
participant Op as LocalPendingOp
participant S as ShareDB
F->>State: 本地变化
State->>Op: localVersion + 1<br/>remoteBaseVersion = 10
S-->>State: 当前云端已到 12
State->>S: 拉取 10 → 12 的远端 ops
State->>Op: transform<br/>remoteBaseVersion = 12
Op->>S: 提交本地 ops
S-->>State: ack,云端版本 = 13
State->>Op: acked = true<br/>remoteVersion = 13
这几个版本不能互相替代。localVersion 是本地序号;remoteBaseVersion 表示操作所基于的云端版本;remoteVersion 记录已确认操作的云端落点。把它们都叫“版本号”而不区分含义,会让离线修改和远端更新难以对账。
远端内容有变化时,同一轮同步还会把更新后的结构转回 Markdown,写回本地,并通知当前页面刷新。页面树另走一条投影链路:先把远端树映射回本地 manifest,再处理新增页面正文。
方案取舍
这套结构保留了文件的开放性,也复用了云端的操作模型,但并不是免费的。
首先,Markdown 与 JsonML 要经过转换。目标是保留支持的编辑器语义,而不是逐字保存任意 Markdown 写法。比如有些结构需要扩展语法才能表达;导出时排版也可能规范化。因此,文件相似度不能直接等同于编辑语义是否保留。
其次,当前页面差异算法比较顶层 block:同一位置的块改变时生成块替换,超出长度的块被删除或追加;结构不适用时会退回整体替换。它实现简单,却不是逐字符 diff,也没有做复杂的块移动识别。一个局部编辑产生的操作,可能比读者直觉中的“改了几个字”更大。
这直接影响对并发的期待。JSON0 transform 用于把操作对齐到远端变化后的结构,不代表系统理解了用户想保留哪一句话。尤其同一块内的并发改写,不能承诺都能得到符合双方意图的结果。当前没有让用户逐项选择冲突内容的界面。
最后,多了一层需要维护的本地状态。扫描索引可以重建,但 pendingOps 承载尚未提交的变化,不能把整个同步目录都当作可以随手清理的缓存。
结果与边界
从当前实现可以确认:本地文件变化有持久化入队入口,未登录和账号不匹配有明确分支,远端领先时有回拉与 transform 路径,远端正文和页面树也有写回本地的入口。这些是代码层面的行为,不等于已经证明所有异常时序都安全。
例如,云端确认与本地状态写入是不同动作。网络中断、提交超时、进程在关键时刻退出,都需要专门的故障验证;仅看到 pendingOps 落盘,不能据此宣称“永不丢失”或“绝不重复提交”。同样,本文没有同步吞吐、延迟或线上故障率数据。
对于本地优先应用,我认为这个设计值得复用的部分是职责分离:文件表达当前内容,同步状态表达变化与进度。先明确两者的关系,再讨论格式兼容、冲突体验和异常恢复,问题会具体得多。
#架构设计 #技术实现 #产品设计 #Notedeep