开始前,会这些就够了
- 能读懂 Python 字典、列表、字符串替换与断言
- 知道 JSON 对象的键是字符串,数组使用位置索引
- 可先读部分更新的变化预览,本篇继续区分展示路径与可传给工具的定位语法
一份资料同时有字段 a/b,以及对象 a 里面的字段 b。助手想读取前者,却把路径直接拼成 /a/b,程序顺利找到了一个值,只是找到了后者。没有异常,并不意味着目标正确;路径本身也需要明确的表示规则。
本篇实现一个只读定位器。它接收已解析 JSON 和普通字符串形式的 JSON Pointer,输出拆开的片段、每层容器类型与最终值。通过十个成功场景和八个错误场景,亲手检查“字段名里包含分隔符”与“真的向下走一层”怎样区分。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- json_pointer_read.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B json_pointer_read.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。已解析 JSON 与只读普通 JSON Pointer 字符串;不处理 URI fragment、JSONPath、权限判断或真实资源写入。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先写清键名序列,再把它编码为路径
想访问单个字段 a/b,先把意图表示为只有一个元素的列表:a/b。想进入 a 再找 b,则是两个元素。不要让一段含斜杠的文字同时负责表达键名与层级,否则拼接步骤就可能改变原本的目标。
RFC 6901规定片段中的波浪号写作 ~0,斜杠写作 ~1。本例 encode 因而将单个键 a/b 变成 /a~1b。程序分别读取朴素路径与正确路径,断言两次得到不同值,反例并非仅仅展示两个看起来不同的字符串。
把目标先存成片段列表还有一个好处:每个元素都能和原始对象的键名逐项核对。应用可以在展示中把层级排成列表,直到提交工具前才进行编码;不必让模型手工猜哪些字符需要替换。编码完成后仍要对目标值做验收,语法正确本身不会证明用户意图被理解正确。
~01 的解码顺序能把目标带到另一处
文档里特意同时放入键 ~1 和键 /。正确解码 /~01 得到的是键 ~1;若先把 ~0 变成波浪号,再把 ~1 变成斜杠,就会把同一段文字处理两次,错误地走到键 /。脚本用两种替换顺序给出可直接比较的结果。
decode 先检查所有波浪号转义是否属于允许形式,再按 ~1、~0 的顺序解码。不能看到未知的 ~2 就原样放过,否则不同客户端可能对同一条路径产生不同解释。编码也有自己的先后顺序:先处理原有波浪号,再处理斜杠,避免把新生成的转义标记再次改写。
空字符串和一个斜杠,指向不同地方
本地文档有一个空文字键。空指针没有任何片段,直接得到整份文档;只有一个斜杠时,则产生一个空片段,读取空键的值。很多自行编写的 split 逻辑会把空片段过滤掉,结果把两种目标混在一起。这里特意保留它。
nil 成员存在,而且它的值是 null,属于成功定位;missing 成员根本不存在,则是错误。报告的 status 与 value 分开记录,因此空值不会被当成“查找失败”。不要用 value 是否为真来决定成功,否则零、空文字和 false 也可能被错误丢掉。
可以先在纸上画出根对象,把空键、nil和missing分别标注:前两个在对象里有位置,第三个没有。随后只改变指针,保持文档不动,核对输出的成功状态与值。这样就能把数据里真的存了空值,与定位过程中找不到成员区分开;后续工具不能把两者都显示成一个空白结果。
相同数字片段,要看眼前是对象还是数组
根对象中的键 01 可以按原字面读取。走进 items 数组后,01 却不能当作索引一;本例的数组索引必须满足零或不带前导零的十进制写法。trace 会记录每一步当前容器是 object、array 还是 scalar,帮助解释同一片段为什么在不同位置得到不同处理。
items/- 也不能读出一个已有值。JSON Pointer 中它指向数组尾部之后的位置;RFC 6902 的 add才为这个位置赋予追加含义。本项目只有读取,遇到它就报错,没有偷偷追加空值。读取和修改操作不能因为共享路径语法就混用动作语义。
JSON 字符串和网址片段,别一起解码
路径放进 JSON 消息时,先由 JSON 编码器处理引号、反斜杠等字符;接收端先解析 JSON,再交给 Pointer 解码器。脚本做一次 json.dumps、json.loads 往返,证明读回的路径仍指向同一字段。两层编码各自只处理自己的问题,不要用一轮手写替换代替。
以 # 开头的 URI fragment 是另一种表示,本例明确不接收。普通 Pointer 里的百分号文字也不会自动做网址解码,percent%2F 在本题中就是一个原样键名。RFC 6901 还要求媒体类型明确声明片段语法,不能仅凭内容是 JSON,就认定任意网址后的片段一定按 Pointer 解释。
实际工具若要求 URI fragment,适配器必须明确加入它自己的编码与解码层,再测试包含百分号、空格和非ASCII文字的目标。本篇没有实现这条入口,因此看到 #/a~1b 报错是范围约束发挥作用,不是用删除井号的捷径假装支持完整的URI表示。
运行十个成功与八个错误场景
保存代码为 json_pointer_read.py,在独立目录运行。只用标准库,不联网,也不修改输入资料。脚本写 json-pointer-report.json 与 json-pointer-ledger.csv,前者保留原文档、片段、逐层轨迹与值,后者列出每个场景和错误原因。
先看 literal_slash 和 nested,再比较 root 与 empty_key,最后检查 array_leading_zero、append_not_read 和 uri_fragment。断言还对含引号、反斜杠、点号及百分号的片段做编码解码往返;原文档在整个过程后保持不变。
成功定位返回的是值的独立副本。脚本另取整份根结果,修改副本里的嵌套文字,再断言原文档依旧不变。这让后续展示或加工步骤不能通过共享对象顺手改坏输入;但它仍只是内存副本,不是数据库快照,也不保证另一进程里的真实资料没有改变。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Read-only plain JSON Pointer string representation; no URI fragments or JSONPath."""
from copy import deepcopy
import csv
import json
from pathlib import Path
import re
def encode(tokens):
if any(not isinstance(token, str) for token in tokens):
raise ValueError("string_tokens_required")
return "".join("/" + token.replace("~", "~0").replace("/", "~1") for token in tokens)
def decode(pointer):
if not isinstance(pointer, str):
raise ValueError("pointer_must_be_string")
if pointer == "":
return []
if not pointer.startswith("/"):
raise ValueError("plain_pointer_must_start_with_slash")
tokens = pointer[1:].split("/")
for token in tokens:
if re.search(r"~(?:[^01]|$)", token):
raise ValueError("invalid_escape")
return [token.replace("~1", "/").replace("~0", "~") for token in tokens]
def resolve(document, pointer):
tokens, node, trace = decode(pointer), document, []
for depth, token in enumerate(tokens):
kind = "object" if isinstance(node, dict) else "array" if isinstance(node, list) else "scalar"
trace.append({"depth": depth, "token": token, "container": kind})
if isinstance(node, dict):
if token not in node:
raise ValueError("missing_object_member")
node = node[token]
elif isinstance(node, list):
if not re.fullmatch(r"0|[1-9][0-9]*", token):
raise ValueError("invalid_read_array_index")
index = int(token)
if index >= len(node):
raise ValueError("array_index_out_of_range")
node = node[index]
else:
raise ValueError("cannot_descend_into_scalar")
return {"tokens": tokens, "value": deepcopy(node), "trace": trace}
def main():
document = {"a/b": "literal slash key", "a": {"b": "nested key"}, "": "empty key",
"m~n": "tilde key", "~1": "literal tilde-one", "/": "slash key",
"items": ["first", {"name": "second"}], "01": "object zero-one", "nil": None,
"percent%2F": "literal percent text"}
original = deepcopy(document)
valid = [("root", "", document), ("empty_key", "/", "empty key"),
("literal_slash", "/a~1b", "literal slash key"), ("nested", "/a/b", "nested key"),
("tilde", "/m~0n", "tilde key"), ("escape_order", "/~01", "literal tilde-one"),
("array", "/items/1/name", "second"), ("object_digits", "/01", "object zero-one"),
("present_null", "/nil", None), ("literal_percent", "/percent%2F", "literal percent text")]
invalid = [("array_leading_zero", "/items/01", "invalid_read_array_index"),
("append_not_read", "/items/-", "invalid_read_array_index"),
("out_of_range", "/items/2", "array_index_out_of_range"),
("bad_escape", "/a~2b", "invalid_escape"), ("trailing_tilde", "/m~", "invalid_escape"),
("uri_fragment", "#/a~1b", "plain_pointer_must_start_with_slash"),
("missing", "/missing", "missing_object_member"),
("scalar", "/nil/x", "cannot_descend_into_scalar")]
rows = []
for name, pointer, expected in valid:
result = resolve(document, pointer)
assert result["value"] == expected
assert encode(result["tokens"]) == pointer
rows.append({"case": name, "pointer": pointer, "status": "resolved", **result})
for name, pointer, expected_error in invalid:
try:
resolve(document, pointer)
except ValueError as error:
assert str(error) == expected_error
rows.append({"case": name, "pointer": pointer, "status": "error", "reason": str(error)})
else:
raise AssertionError("invalid pointer accepted")
assert document == original
result_copy = resolve(document, "")["value"]
result_copy["a"]["b"] = "changed by a later consumer"
assert document == original
naive_pointer = "/" + "/".join(["a/b"])
correct_pointer = encode(["a/b"])
assert resolve(document, naive_pointer)["value"] != resolve(document, correct_pointer)["value"]
wrong_decoded = "~01".replace("~0", "~").replace("~1", "/")
assert wrong_decoded == "/" and decode("/~01") == ["~1"]
wire = json.dumps({"path": correct_pointer})
assert resolve(document, json.loads(wire)["path"])["value"] == "literal slash key"
for tokens in [[], [""], ["/", "~", "~1"], ["a.b", "x%2Fy", "01"], ['quote"', "\\"]]:
assert decode(encode(tokens)) == tokens
report = {"scope": "parsed JSON fixtures; exact-key, read-only plain pointer strings",
"document": document, "naive_pointer": naive_pointer, "correct_pointer": correct_pointer,
"json_wire": wire, "cases": rows, "assertions": "passed"}
Path("json-pointer-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
with Path("json-pointer-ledger.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["case", "pointer", "status", "reason"])
writer.writeheader()
writer.writerows({key: row.get(key, "") for key in writer.fieldnames} for row in rows)
print("naive /a/b -> nested key; correct /a~1b -> literal slash key")
print("escape ~01 -> ~1; array /items/01 and /items/- -> errors")
print(f"PASS: {len(valid)} resolved, {len(invalid)} rejected; token roundtrips and input isolation")
if __name__ == "__main__":
main()
怎样运行:Python 3.10+,在独立练习目录运行:python json_pointer_read.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。已解析 JSON 与只读普通 JSON Pointer 字符串;不处理 URI fragment、JSONPath、权限判断或真实资源写入。
运行后对照结果
naive /a/b -> nested key; correct /a~1b -> literal slash key
escape ~01 -> ~1; array /items/01 and /items/- -> errors
PASS: 10 resolved, 8 rejected; token roundtrips and input isolation知道准确位置,与按条件搜索是不同任务
RFC 9535 附录 C区分 JSON Pointer 与 JSONPath:前者用于定位一个具体值,后者也支持按查询表达式查找多个值。本例不能接收通配符、筛选条件或递归搜索,不能把一个Pointer字符串改个名字就当成JSONPath。
如果需求是找出所有匹配名称的对象,应先完成查询并处理候选,再确定具体位置;不要随便拿第一个匹配结果生成写入路径。数组位置尤其依赖当前文档,前面插入一项后,原路径可能仍合法却指向另一项。位置不是永久业务身份,修改前还要核对当前对象。
验收定位结果,也验收没有被猜测的部分
2026-10-15 已在独立目录实际运行,十种成功、八种拒绝及额外往返断言全部通过。源码与文章完整代码一致。交付时应能从指针回到片段,再回到逐层容器和最终值,而不只展示一行“路径合法”。
程序面向已经解析、成员唯一的虚构JSON数据;没有处理重复成员文本的读取入口,没有实现URI片段、JSONPath或任何写操作。对象键按原字符匹配,不自动修剪空格或规范化名称;这份定位实验也不能代替工具权限、文档版本或真实用户意图的验证。
记住这三点
- 先保留原始键名片段,再按具体路径语法编码,避免分隔符改变目标。
- 解码顺序、空片段、对象与数组类型都影响最终定位;成功返回值不等于目标正确。
- 普通Pointer字符串、URI片段、JSONPath及写操作规则需要分别实现和验收。
别把例子用过头
- 用斜杠直接拼接未编码键名,或把空片段过滤掉。
- 按错误顺序重复解码,把~01错当成斜杠。
- 把数字对象键当数组下标,或把只读定位器当成支持追加与查询的完整工具。
自己试一小步
自行设计一份含特殊键名与嵌套数组的资料,怎样证明每条工具路径都找到了原本想要的值?
- 构造至少六种新的键名,包含空文字、斜杠、波浪号、数字及一个非ASCII名字;为每个目标先写片段列表。
- 分别生成正确指针与一种容易写错的指针,记录两者的实际定位结果或错误。
- 让数组前面新增一项,再核对旧路径;解释位置仍合法时为何目标可能已变。
- 交付原始文档、路径账本、运行日志与入口范围说明,不接真实写入工具。
做到这些,就算完成
- 每个成功结果能否追溯到明确的原始片段和容器类型?
- 空值、缺失成员、数组前导零与尾后位置是否分开处理?
- 是否明确区分Pointer、URI片段、JSONPath和业务资源身份?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- RFC 6901:JSON Pointer (新标签页)↗
核读第3至7节;支持片段转义、解码次序、对象/数组定位、字符串表示与URI片段的区别和错误出口。
- RFC 6902:JSON Patch 的 add (新标签页)↗
核读第3节、第4.1节的路径及数组追加规则;用于说明同一路径语法的动作含义由应用规范定义。
- RFC 9535:JSONPath 与 JSON Pointer (新标签页)↗
核读Introduction、术语Normalized Path与附录C;支持具体值定位和查询选取多值的目的差异。