选择合并方式前,先说明离线编辑冲突
本文目录
离线编辑器需要的不只是“联网后同步”。本地草稿遇到较新的已保存记录时,应用必须有明确决定:保留本地文字、展示两个版本、提出组合建议,还是拒绝写入?
先描述历史,再选择合并算法。把三件事分开:编辑开始时的版本、尚未保存的草稿,以及最近从服务读取的版本。随后规定被拒绝的写入和结果不确定的保存应如何显示。
在 HTTP 设计中,If-Match 可以让写入以实体标签匹配为前提。RFC 9110 要求使用强比较;条件不满足时,不得执行所请求的方法。如果能够确认修改此前已应用,RFC 允许返回成功响应;否则,前置条件失败可以产生 412 Precondition Failed。它没有定义字段合并策略。RFC 9110,第 13.1.1 节
建立一条虚构记录
两台设备 Laptop A(笔记本 A)和 Tablet B(平板 B)编辑虚构笔记 N17。本练习假设两者都有所需权限。完整可编辑记录恰好有两个字段:
{"title":"Release checklist","body":"Check links."}
服务随每个版本返回一个强实体标签。下文的 "n7"、"n8" 等标签是虚构的不透明标签。只比较它们是否精确相等,不要解析其中的数字,也不要把它们当作时间戳。
这些示例采用一份简单服务契约:替换写入同时提交两个字段,以及它所基于的标签。匹配时,写入一起替换两个字段,并获得一个新标签。不匹配时,返回 412,两个字段都不改变。这个虚构服务不采用 RFC 中“此前已应用则返回成功”的选项。按本应用的策略,缺少标签也会被拒绝。
实现时,比较和写入必须组成一次条件更新。先读取标签、在客户端检查,再发出无条件写入,不符合这份契约。
每台设备都保留一个编辑资料包,包含记录 ID、基线标签、完整基线记录和完整本地草稿。刷新远端版本不能覆盖这份资料包。以下历史是手工推导的预期,不是发送到服务器的流量。
情况一:两台设备修改了不同字段
两台设备最初都读取 "n7"。笔记本 A 离线修改标题,平板 B 在线修改正文。
| 步骤 | 设备事件与写入依据 | 最近保存的标签 | 完整已保存标题 | 完整已保存正文 |
|---|---|---|---|---|
| D0 | A 和 B 读取初始笔记 | "n7" |
Release checklist | Check links. |
| D1 | A 离线编辑;没有写入 | "n7" |
Release checklist | Check links. |
| D2 | B 以 If-Match: "n7" 写入正文修改,收到成功响应 |
"n8" |
Release checklist | Check links and spelling. |
| D3 | A 重新连接,以 If-Match: "n7" 提交完整本地草稿 |
"n8" 不变;412 |
Release checklist | Check links and spelling. |
| D4 | A 明确读取最新笔记;没有写入 | "n8" |
Release checklist | Check links and spelling. |
| D5 | A 复查组合提案,并确认一次基于 "n8" 的写入 |
"n9";成功 |
Release checklist v2 | Check links and spelling. |
D3 时,A 保留的编辑资料包仍是:
{
"recordId": "N17",
"baseTag": "\"n7\"",
"base": {
"title": "Release checklist",
"body": "Check links."
},
"localDraft": {
"title": "Release checklist v2",
"body": "Check links."
}
}
服务没有接受这份本地草稿,此时显示“已保存”是不对的。用最新远端记录替换草稿,也会丢掉 A 尚未保存的标题。
D4 之后,逐字段与原基线比较:
| 字段 | "n7" 时的基线 |
A 的草稿 | "n8" 时的远端值 |
选定提案 |
|---|---|---|---|---|
| 标题 | Release checklist | Release checklist v2 | Release checklist | 采用 A 修改的标题 |
| 正文 | Check links. | Check links. | Check links and spelling. | 采用远端修改的正文 |
因为每个字段都只在一侧改变,这个应用会提供组合提案。这是应用规则,不是 HTTP 功能。D5 前仍由人复查组合结果。对于更大的记录,假设字段相互独立可能并不合适:即使键不同,标题与正文也可能互相依赖。
由此得到的示意请求为:
PUT /notes/N17
If-Match: "n8"
Content-Type: application/json
{"title":"Release checklist v2","body":"Check links and spelling."}
这些请求形状属于虚构服务,不是在调用 CueSpec API。
较新的编辑可能使已复查的提案失效
考虑 D4 之后的另一条分支,用它替换 D5。平板 B 以 If-Match: "n8" 把标题改为 Urgent release checklist;服务将这条完整笔记保存为 "n9",正文仍为 Check links and spelling.。随后 A 仍以 "n8" 提交上面的提案。
服务再次返回 412,保留 B 的完整 "n9" 记录。A 保留草稿,并把提案标为已过时。它必须重新读取并展示新出现的标题冲突,不能只把请求中的 "n8" 换成 "n9" 再发送:那会把对先前组合的复查,变成覆盖后来决定的授权。
A 尚未基于新记录作出新选择时,再按 Save 只会打开冲突复查,不会发送写入。这个明确暂停的步骤,是所选界面策略的一部分。
情况二:两台设备修改了同一字段
从相同记录值开始一段独立历史,这次标签是 "s7",不是接着 "n9" 的历史继续。
| 步骤 | 事件与写入依据 | 已保存标签 | 完整已保存标题 | 完整已保存正文 |
|---|---|---|---|---|
| S0 | 两台设备读取初始笔记 | "s7" |
Release checklist | Check links. |
| S1 | A 把离线标题改为 Ready for review;没有写入 |
"s7" |
Release checklist | Check links. |
| S2 | B 使用 "s7" 保存标题 Needs wording review |
"s8" |
Needs wording review | Check links. |
| S3 | A 使用 "s7" 提交本地标题和未修改的正文 |
"s8" 不变;412 |
Needs wording review | Check links. |
| S4 | A 读取 "s8" 并比较三个版本 |
"s8" 不变 |
Needs wording review | Check links. |
| S5 | 用户明确输入 Ready for wording review,并以 "s8" 保存 |
"s9";成功 |
Ready for wording review | Check links. |
S4 时,标题比较包含三个不同值:基线是 Release checklist,本地草稿是 Ready for review,远端值是 Needs wording review。任何一项修改都不能从另一项推导出来。应用把三者放在一起展示,并在用户决定期间保留本地文字。
S5 使用用户新写的标题。应用没有擅自认定,组合两个短语就保留了任一作者的意图。其他明确选择也可以成立:保留远端标题,或选用本地标题,并以最近复查的标签提交。应用应清楚说明这种覆盖。
没有作出选择,就不会出现新的写入。关闭冲突面板会让草稿继续处于未解决状态,不代表已接受远端值作为解决方案。能否在崩溃或应用重启后保留该草稿,是另一项需要设计和测试的存储要求。
保存响应丢失,需要不同提示
超时不能证明条件更新被拒绝。再看一条独立分支:
| 步骤 | 事件与写入依据 | 这段虚构历史中实际保存的标签 | 完整已保存标题 | 完整已保存正文 | A 知道什么 |
|---|---|---|---|---|---|
| L0 | 两台设备读取初始笔记 | "l7" |
Release checklist | Check links. | 已知基线 |
| L1 | A 以 "l7" 写入标题 Release checklist v2;服务已应用,但响应丢失 |
"l8" |
Release checklist v2 | Check links. | 保存结果未知 |
| L2 | B 读取 "l8",修改正文,并以 "l8" 保存 |
"l9" |
Release checklist v2 | Check links and spelling. | A 尚未读取这个版本 |
| L3 | A 明确刷新并收到 "l9" |
"l9" 不变 |
Release checklist v2 | Check links and spelling. | 当前标题与草稿一致;当前正文有较晚修改 |
L3 前显示“保存结果未知,请检查最新版本”,并保留编辑资料包。如果读取也失败,这种不确定状态继续保留。
L3 后,A 可以说最新记录包含自己期望的标题。但这个观察不能确定是哪项请求或哪位作者产生了它。本示例没有操作回执端点,所以即使期望标题已经存在,原请求的回执仍未确认。让用户明确确认当前记录,再主动清除本地待定草稿;不要重发旧的完整记录,覆盖 B 较新的正文。
先选择结果,再选择算法
| 情况 | 选定的下一步 | 现在允许写入吗? | 继续保留什么? |
|---|---|---|---|
| 旧标签被拒绝 | 读取最新内容,展示冲突复查 | 不自动写入 | 基线和本地草稿 |
| 不同字段被修改 | 提供组合提案 | 只有复查后,以最近复查的标签写入 | 确认成功前保留本地草稿 |
| 同一字段出现不同值 | 展示基线、本地和远端 | 只有明确选择后才允许 | 解决前保留尚未选定的本地文字 |
| 复查后又出现新远端版本 | 使提案失效,重新读取并决定 | 不重放过时提案 | 草稿和未解决的决定 |
| 保存响应丢失 | 标为结果未知,读取当前记录 | 不自动发起替换写入 | 待定编辑资料包 |
| 解决冲突前按 Save | 回到冲突复查 | 不允许 | 未解决草稿 |
复制一份离线冲突 brief
使用本文完整的 D、S、L 历史,为虚构笔记 N17 准备一份冲突处理设计。
不要调用 API,也不要实现生产同步。
可编辑记录恰好包含 title 和 body。把带引号的标签视为不透明的强验证器。
我们选择的服务只在提交标签匹配时一起替换两个字段;
不匹配则返回 412,不应用修改。不采用“此前已应用则返回成功”的优化。
把记录 ID、基线标签、完整基线和本地草稿,与最新远端读取分开保留。
刷新不能抹去本地编辑。D 情况提供需复查的组合;
S 情况要求对同字段冲突作出明确选择。
如果拟议保存之前出现较新版本,使提案失效并取得新决定。
不要更新标签后默默重放旧的完整替换请求。
对于 L,区分保存回执未知、写入被拒绝,以及观察到期望的当前标题。
保留较晚的正文修改。冲突解决前按 Save 不发送写入。
指出仍需实现工作的重启后保留草稿及原子条件更新要求,
不要假设这些机制已经存在。
返回完整结果记录、UI 状态和决策步骤。
把纸面预期与之后任何实际执行的检查分开。
明确预期结果后,再选择自动合并、冲突面板或更简单的单写入者策略。真正有用的起点是一段历史:它说清谁的未保存工作仍被保留、写入依赖哪个版本,以及凭什么证据,界面才可以显示“已保存”。