qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

字段叫 a/b,为什么工具找成了 a 里面的 b?

做一个只读 JSON Pointer 定位器,把字段名、路径片段、数组索引和外层字符串编码分开,留下逐层定位账本。

展开本文目录
  1. 下载完整练习包
  2. 先写清键名序列,再把它编码为路径
  3. ~01 的解码顺序能把目标带到另一处
  4. 空字符串和一个斜杠,指向不同地方
  5. 相同数字片段,要看眼前是对象还是数组
  6. JSON 字符串和网址片段,别一起解码
  7. 运行十个成功与八个错误场景
  8. 知道准确位置,与按条件搜索是不同任务
  9. 验收定位结果,也验收没有被猜测的部分
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 能读懂 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。断言还对含引号、反斜杠、点号及百分号的片段做编码解码往返;原文档在整个过程后保持不变。

成功定位返回的是值的独立副本。脚本另取整份根结果,修改副本里的嵌套文字,再断言原文档依旧不变。这让后续展示或加工步骤不能通过共享对象顺手改坏输入;但它仍只是内存副本,不是数据库快照,也不保证另一进程里的真实资料没有改变。

展开收起代码 · 小实验python · 109 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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错当成斜杠。
  • 把数字对象键当数组下标,或把只读定位器当成支持追加与查询的完整工具。

自己试一小步

自行设计一份含特殊键名与嵌套数组的资料,怎样证明每条工具路径都找到了原本想要的值?

  1. 构造至少六种新的键名,包含空文字、斜杠、波浪号、数字及一个非ASCII名字;为每个目标先写片段列表。
  2. 分别生成正确指针与一种容易写错的指针,记录两者的实际定位结果或错误。
  3. 让数组前面新增一项,再核对旧路径;解释位置仍合法时为何目标可能已变。
  4. 交付原始文档、路径账本、运行日志与入口范围说明,不接真实写入工具。

做到这些,就算完成

  • 每个成功结果能否追溯到明确的原始片段和容器类型?
  • 空值、缺失成员、数组前导零与尾后位置是否分开处理?
  • 是否明确区分Pointer、URI片段、JSONPath和业务资源身份?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • RFC 6901:JSON Pointer (新标签页)↗IETF / RFC Editor · 英文

    核读第3至7节;支持片段转义、解码次序、对象/数组定位、字符串表示与URI片段的区别和错误出口。

    2026-10-15 已实际打开官方公开正文并核读所列部分;本例仅实现已解析数据上的普通字符串只读定位,未宣称完整接入协议。

  • RFC 6902:JSON Patch 的 add (新标签页)↗IETF / RFC Editor · 英文

    核读第3节、第4.1节的路径及数组追加规则;用于说明同一路径语法的动作含义由应用规范定义。

    2026-10-15 已实际打开官方公开正文并核读所列部分;程序不执行Patch,不添加或删除任何JSON字段。

  • RFC 9535:JSONPath 与 JSON Pointer (新标签页)↗IETF / RFC Editor · 英文

    核读Introduction、术语Normalized Path与附录C;支持具体值定位和查询选取多值的目的差异。

    2026-10-15 已实际打开官方公开正文并核读所列部分;未实现JSONPath,也未声称两种语法可在未知文档结构时任意互换。