开始前,会这些就够了
- 能读懂 Python 字典、递归函数与 assert 断言
- 理解 JSON 的对象、数组、
null和布尔值 - 先读参数格式与业务规则,本篇处理通过检查后究竟改哪些字段
用户说“把电话改为 010-2000”,助手却交给工具一张所有栏位都齐全的表:电话填了新号码,没提到的营业时间填 null。工具按自己的更新规则执行,结果电话变了,营业时间也被删了。每个字段的格式都可能合法,问题发生在“没提到”被改写成了“请删除”。
这次做一个离线修改预览器。给它同一份虚构店铺资料和九张更新单,观察究竟改了什么。目标是把人的修改范围保留到执行入口;程序不识别自然语言,没有真实模型或商户接口,也不会修改你的店铺。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- sparse_update.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B sparse_update.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。虚构店铺数据与本地 JSON Merge Patch 规则;不调用模型,不解释自然语言,也不修改真实商户。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
只想改电话,实际会改变哪些字段?
这份更新单按 JSON Merge Patch 解释。先只改电话,再试着把没填写的营业时间补成 null,观察结果。
本次改变 1 个字段。phone 仍存在,值变为新号码。 hours 没有出现在更新单中,原营业时间保留。
修改前
{
"name": "虚构茶铺",
"phone": "010-1000",
"hours": "09:00-18:00"
}发送的更新单
{
"phone": "010-2000"
}按约定应用以后
{
"name": "虚构茶铺",
"phone": "010-2000",
"hours": "09:00-18:00"
}| 字段 | 修改前 | 修改后 | 有变化吗 |
|---|---|---|---|
| phone | "010-1000" | "010-2000" | 是 |
| hours | "09:00-18:00" | "09:00-18:00" | 否 |
这里仅在当前页面计算固定的虚构资料,没有发送请求或修改商户。null 的含义属于 JSON Merge Patch;不能据此猜测其他工具的更新规则。数组和根值替换等情况见正文与完整脚本。
没提到、删掉、留空,先分成三种意思
设想纸面表格有电话和营业时间两栏。用户只圈了电话,并不意味着要把另一栏擦掉。本题先约定三种意图:更新单里没有某个键,保留原值;键对应 null,删除这个成员;键对应空字符串,把这个成员的值改成空文字。后两种结果看起来都像“没号码”,但数据结构不同。
这套约定来自本例采用的 JSON Merge Patch。它不是所有更新接口的统一行为。接入一个真实工具前,应读清楚该接口如何表达删除与空值,再做映射;仅看到 HTTP 方法叫 PATCH,还不足以确定正文采用哪种格式。
更新单写本次要改的内容
phone_only 的更新单只有 phone。执行后,营业时间、联系邮箱、标签都保留。dangerous_defaults 只是额外塞进 hours: null,就多出一项删除。预览器不会知道这个 null 是用户明确要求,还是某层代码自动补齐;它只按收到的单子计算结果。
因此从对话整理字段时,要保留“用户没有指定”这一信息。不能先建立所有值为 null 的完整字典,再填入识别到的字段。完整资源用于描述现在有什么,部分更新用于描述这次改变什么,两者长得相似,承担的任务却不同。这里用手工构造的字典代替理解步骤,只验证后续变化。
可以给每次修改留一张小卡:用户明确提到哪个字段,要求设置还是删除,生成的更新单用了哪种表示。用户说“我不知道电话号码”并不自动等于“删除已有号码”;没有足够线索时,更新单不应自行扩大范围。这样最后的预览才有原始意图可对照,而不是只拿执行结果证明自己理解正确。
对象可以往里改,数组在这里整体替换
contact 里面还有 email 和 note。本例只提交 contact.note,新便条会覆盖旧便条,邮箱仍在。程序递归进入对象,是为了把修改范围推进到更小的位置;直接用新 contact 覆盖整个旧 contact,就会得到另一种结果。
tags 是数组。提交只含 tea 的新数组后,quiet 不再保留;这不是逐项删除命令,而是把整份列表换掉。数组的下标会随插入变化,本篇没有实现按元素身份合并。若业务希望追加一个标签,应选择明确支持该操作的工具,不能把“数组也会递归合并”当作默认规则。
手工跟一次递归:先复制整份门店资料,看到 contact 后拿出旧联系对象,再只处理更新单里的 note,最后把新联系对象放回原位置。email 没被列入更新单,所以没有进入删除或替换分支。若旧 contact 原本是文字,而新值是对象,则先从空对象开始,旧文字不会被神奇地转换成邮箱或便条。
预览要同时展示位置、存在性和数值
changes 会生成逐项变化账本。path 是一串键名,例如 contact、note,旁边各存修改前后的 present 与 value。删除电话时 after.present 为 false;改为空文字时则为 true。只输出 value 会让成员缺失与成员存在但为空混到一起,读者便无法检查真正的结果。
路径列表只是本地展示格式,不是 JSON Pointer,也不是可提交给服务的 JSON Patch。若输入 true,输出整数 1,Python 普通相等比较可能把它们看作相等;本例用 JSON 表示比较这组受控输入,确保类型变化进入预览。数值表示差异也可能被记录,它不是通用 JSON 语义等价判定器。
留下原稿,才能比较变化
merge_patch 返回独立结果,原资料与更新单都不原地修改。Python 的 copy 文档解释了赋值、浅复制和深复制的区别。本例的数据只有普通 JSON 值,使用 deepcopy 保留嵌套资料,让之后改动结果里的便条也不会悄悄改坏原稿。
空对象更新不会改变一个已有对象。把根更新值设为 null,则替换整个根结果;这与对象成员内的 null 删除语义不同。脚本故意保留 root_null 对照,提醒接入层决定是否允许整份替换。预览函数能计算一种变化,不代表业务应该允许它。
输出报告保留的是解析后的数据,没有承诺保留输入文本的空格、缩进或键排列。读者应比较字段含义与存在性,而不是把两份格式不同的 JSON 文字直接当成两次业务修改。对于电话这类文本,也不要为了看起来整齐而自行去掉前导零;显示格式与更新内容应分开决定。
运行九张更新单,再回看每一项变化
把完整代码保存为 sparse_update.py,在独立目录运行。只用 Python 标准库,无账号、无网络。脚本写出 sparse-update-report.json,里面保留每组原稿、更新单、结果与变化账本;重复运行会覆盖同名练习报告。
先预测电话删除和空电话各是什么结构,再检查 JSON。除九组主流程,断言还覆盖删除不存在的键、已有 null 的保留、根对象与数组的替换,以及嵌套副本互不影响。输出的 changed 数表示预览条目数,不能当作接口调用次数或真实服务器修改数量。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Local JSON Merge Patch exercise; no HTTP, model, or real merchant changes."""
from copy import deepcopy
import json
from pathlib import Path
def same_json(left, right):
return json.dumps(left, sort_keys=True, allow_nan=False) == json.dumps(
right, sort_keys=True, allow_nan=False)
def merge_patch(target, patch):
# Operates on already parsed JSON values. Object members with null are removed.
if not isinstance(patch, dict):
return deepcopy(patch)
result = deepcopy(target) if isinstance(target, dict) else {}
for key, value in patch.items():
if value is None:
result.pop(key, None)
else:
result[key] = merge_patch(result.get(key), value)
return result
def snapshot(obj, key):
return {"present": key in obj, "value": deepcopy(obj.get(key))}
def changes(before, after, path=()):
"""Readable leaf changes; path is a list of keys, NOT a JSON Pointer."""
if isinstance(before, dict) and isinstance(after, dict):
rows = []
for key in sorted(before.keys() | after.keys()):
old, new = snapshot(before, key), snapshot(after, key)
if old["present"] and new["present"]:
rows.extend(changes(old["value"], new["value"], path + (key,)))
elif old != new:
rows.append({"path": list(path + (key,)), "before": old, "after": new})
return rows
if same_json(before, after):
return []
return [{"path": list(path), "before": {"present": True, "value": deepcopy(before)},
"after": {"present": True, "value": deepcopy(after)}}]
def run_case(name, before, patch):
original, original_patch = deepcopy(before), deepcopy(patch)
after = merge_patch(before, patch)
assert before == original and patch == original_patch
return {"case": name, "before": original, "patch": original_patch,
"after": after, "changes": changes(before, after)}
def main():
shop = {"name": "Fictional Tea", "phone": "010-1000", "hours": "09:00-18:00",
"contact": {"email": "demo@example.invalid", "note": "upstairs"},
"tags": ["tea", "quiet"], "closed": True}
cases = [
run_case("phone_only", shop, {"phone": "010-2000"}),
run_case("remove_phone", shop, {"phone": None}),
run_case("empty_phone", shop, {"phone": ""}),
run_case("nested_note", shop, {"contact": {"note": "ground floor"}}),
run_case("replace_tags", shop, {"tags": ["tea"]}),
run_case("false_is_a_value", shop, {"closed": False}),
run_case("empty_object", shop, {}),
run_case("dangerous_defaults", shop, {"phone": "010-2000", "hours": None}),
run_case("root_null", shop, None),
]
by_name = {row["case"]: row for row in cases}
phone = by_name["phone_only"]
assert phone["after"]["hours"] == shop["hours"]
assert [row["path"] for row in phone["changes"]] == [["phone"]]
assert "phone" not in by_name["remove_phone"]["after"]
assert by_name["empty_phone"]["after"]["phone"] == ""
assert by_name["nested_note"]["after"]["contact"]["email"] == shop["contact"]["email"]
assert by_name["replace_tags"]["after"]["tags"] == ["tea"]
assert by_name["false_is_a_value"]["after"]["closed"] is False
assert by_name["empty_object"]["changes"] == []
assert "hours" not in by_name["dangerous_defaults"]["after"]
assert by_name["root_null"]["after"] is None
assert merge_patch({"x": None}, {}) == {"x": None}
assert merge_patch({}, {"absent": None}) == {}
assert merge_patch([1, 2], {"a": 1}) == {"a": 1}
assert merge_patch({"a": 1}, [2, 3]) == [2, 3]
assert len(changes({"x": True}, {"x": 1})) == 1
cloned = merge_patch(shop, {})
cloned["contact"]["note"] = "changed later"
assert shop["contact"]["note"] == "upstairs"
report = {"scope": "parsed JSON fixtures; no API adapter or concurrency control",
"cases": cases, "assertions": "passed"}
Path("sparse-update-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
for row in cases:
print(f"{row['case']}: changed={len(row['changes'])}")
print("PASS: missing, null, empty, nested, array, false, root replacement, input isolation")
if __name__ == "__main__":
main()
怎样运行:Python 3.10 或更新版本,在独立练习目录执行:python sparse_update.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。虚构店铺数据与本地 JSON Merge Patch 规则;不调用模型,不解释自然语言,也不修改真实商户。
运行后对照结果
phone_only: changed=1
remove_phone: changed=1
empty_phone: changed=1
nested_note: changed=1
replace_tags: changed=1
false_is_a_value: changed=1
empty_object: changed=0
dangerous_defaults: changed=2
root_null: changed=1
PASS: missing, null, empty, nested, array, false, root replacement, input isolation真实接口可能另附一张“要改哪些栏”的清单
Google AIP-134给出另一种设计:资源数据旁边附 update_mask,列出要更新的字段,并讨论全量替换的风险。这说明“值是什么”和“哪些值参与修改”可以分开表达;不能把这里的 null 删除规则直接套到字段掩码接口。
为真实工具准备适配器时,可以先把用户意图整理成“保留、设置、删除”三类,再按目标接口生成请求。生成后重新计算或读取预览,检查最终变化是否仍与意图一致。本项目没有实现字段掩码转换,也没有把某家服务的默认规则当成通用约定。
验收修改范围,也说明预览的时点
2026-10-15 已在独立临时目录执行脚本,全部内置断言通过,并生成九组报告;文章中的完整代码来自实际执行文件。读者能从 phone_only 的一条变化追到原始输入,也能看到补默认值为什么造成额外删除。
预览只针对传入的那份旧资料。真实服务在预览之后可能被其他人修改,还需要服务端版本条件、原子提交和结果核对。本篇没有完成这些能力,也没有校验电话号码、权限或任意不可信 JSON 输入;这些边界不能由一个本地 PASS 代替。
记住这三点
- 未指定、删除与空文字是三种更新意图,不能都装成
null。 - 按实际接口规则解释对象和数组,并用变化账本检查修改范围。
- 离线预览证明固定输入的变化,不证明真实服务在提交时仍是同一版。
别把例子用过头
- 把完整资源对象直接当成更新单,为未指定字段自动补空值。
- 把所有 PATCH 接口都当成 JSON Merge Patch,或假定数组会逐项合并。
- 只看输出值,不看字段是否存在;把预览成功当作真实写入完成。
自己试一小步
给虚构门店增加地址与配送区域后,怎样确保只修改用户明确指定的部分?
- 自行构造另一份资料及至少六张更新单,覆盖未指定、删除、空文字、嵌套对象与数组替换;运行前写出预期。
- 设计一份中间意图记录,区分保留、设置与删除,再把它转换为本题更新格式。
- 增加一次预览后资料改变的情境,说明原预览还能证明什么,以及提交端需要补什么检查。
- 交付脚本、变化账本和一段供用户确认的自然语言摘要;不要提交真实门店修改。
做到这些,就算完成
- 摘要中的每一项变化能否回到更新单中的明确意图?
- 缺失、
null和空文字是否仍可区分,未指定字段是否保留? - 是否分别说明数组行为、接口格式与预览时点?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- RFC 7396:JSON Merge Patch (新标签页)↗
核读第 1、2、3 节及附录 A;支持对象成员的删除、递归更新、数组及非对象整体替换规则。
- AIP-134:部分更新与字段掩码 (新标签页)↗
核读 Request message 与 PATCH and PUT;支持
update_mask的字段范围设计及全量替换风险,未把它与 Merge Patch 混为同一格式。 - Python 3.10:浅复制与深复制 (新标签页)↗
核读赋值绑定、浅复制和 deepcopy 的说明;支持本例保留嵌套输入副本。