开头先交代一个背景我之前在整理一份配置审计脚本日志里塞满了多层嵌套的 JSON我需要知道“每个叶子节点到底在哪个路径、值是什么”。翻来覆去找方案要么太重要么绕来绕去最后干脆自己写了一个 25 行 Python 实现的小工具代号就叫 Jev。它不是一个框架也不是某个开源项目的缩写就是“JSON Event Visitor”的简写——一个极简的 JSON 叶子节点事件回调工具。这篇文章不谈复杂架构就讲清楚这个 25 行的小东西怎么设计、怎么写、怎么用以及我会在哪些场景下继续用它。适合正在学 Python 递归、想做配置校验、或者单纯想看看“极致精简工具”是怎么取舍的人。1. Jev 是什么一个 25 行的 JSON 叶子节点事件工具1.1 我为什么要写这个工具有段时间我在处理一摞来自数据平台的配置快照结构大概是{project: {env: {debug: true, timeout: 30}}}这种多层嵌套。我需要做的事情很简单把所有叶子节点不再包含子对象的键值对的路径和值全部列出来然后挨个检查是否符合预期。如果用一个现成的 JSONPath 库功能确实多但为了一个简单的遍历动作引入依赖感觉不划算。Jev 就是在这种“杀鸡不想用牛刀”的心态下诞生的。另外我也想借这个小工具验证一个想法用最少的代码把“递归遍历 事件回调 路径拼接”这三个核心点讲清楚。网络上很多遍历 JSON 的代码写得又长又绕动不动就几十行其实真正核心的递归分支只需要三句话。Jev 就是要把这个“三句话递归”放到一个完整可运行的小类里。1.2 它能解决什么问题不解决什么问题Jev 解决的核心问题是给定一段 JSON 文本或一个已解析的 Python 对象按深度优先顺序遍历整棵树凡是遇到叶子节点就把它的路径和值交给一个回调函数处理。这个过程可以用一句话概括它把“嵌套结构”拍平成“路径与值的流”。它不做什么也很重要。Jev 不做数据修改不提供复杂的过滤表达式不支持 JSONPath 语法。它只做事件通知具体拿到路径和值之后要干什么完全由回调函数决定。这种“只负责遍历不负责业务”的设计反而让它变得通用可以拿来做配置校验可以拿来做字段统计也可以拿来做脱敏扫描。对于只需要遍历 JSON 的简单场景它是一个比依赖库更轻、比手写循环更清晰的方案。2. 核心设计递归 回调 路径拼接2.1 判断类型的三板斧Jev 的核心逻辑其实只有一个函数遍历时判断当前节点是哪一类。我把 JSON 节点分成三种情况字典、列表、叶子。判断字典用isinstance(value, Mapping)判断列表用isinstance(value, Sequence)。有人可能会问为什么不直接用dict和list因为用抽象基类能顺手支持OrderedDict、defaultdict甚至一些自定义映射类型兼容性更好还不多花几行代码。列表判断需要注意一个坑字符串和字节串也是Sequence如果不排除它们遍历到hello这种字符串时会被拆成一个个字符结果完全不对。所以条件里必须加上not isinstance(value, (str, bytes))。这三板斧看起来简单但 JSON 的结构无非就是这三种情况的组合只要把它们处理对了整棵树就能被完整覆盖。2.2 路径格式为什么要统一Jev 输出的路径我统一采用了一种混合风格对象键用点号拼接数组下标用方括号拼接。比如{a: [{b: 1}]}里面的1路径就是$.a[0].b。开头统一用$表示根节点这样路径在日志里看起来更清晰也方便阅读者一眼看出层级关系。为什么不用纯点号a.0.b因为数组下标容易和对象键混淆而且很多配置系统本身就是用$开头加方括号来定位 JSON 字段的。为什么不用纯方括号[a][0][b]因为太长打印出来不友好。混合风格是简洁性和可读性之间的折中。当然如果你想把它改成别的风格只需要改emit函数里的字符串拼接那一行。2.3 25 行版本的设计取舍把代码压缩到 25 行必然要做一些取舍。第一个取舍是“类 回调”而不是“生成器”。生成器版本可能更 Pythonic但需要调用方自己写for循环回调模式可以天然适配事件驱动场景。第二个取舍是不处理循环引用。JSON 本身不允许循环引用但在 Python 里如果你用feed方法传入一个自引用的字典递归会无限循环这一版选择直接忽略这种异常情况。第三个取舍是不提供include_root之类的开关根节点默认不触发回调因为根节点通常只有类型信息没有业务价值。这些取舍让代码变得短小但并不意味着它残缺。正好相反正是因为砍掉了“复杂过滤”“错误处理”“路径查询”这些非核心功能递归遍历的骨架才足够清晰读者也能一眼看明白每行代码在干什么。如果你需要更健壮的版本后续扩展也非常简单。3. 完整代码与使用示例3.1 25 行核心代码先看第一版可以直接运行的完整代码。为了阅读方便我加了空行和必要的注释实际有效逻辑行数在 25 行以内。import json from collections.abc import Mapping, Sequence class Jev: def __init__(self, on_leaf): self.on_leaf on_leaf def emit(self, path, value): if isinstance(value, Mapping): for key, item in value.items(): self.emit(f{path}.{key}, item) elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)): for idx, item in enumerate(value): self.emit(f{path}[{idx}], item) else: self.on_leaf(path, value) def load(self, text): self.emit($, json.loads(text)) def feed(self, obj): self.emit($, obj)如果去掉空行和 docstring核心类体只有 22 行左右。load方法是给 JSON 文本用的feed方法是给已经解析过的 Python 对象用的。两个方法内部都只是调用一次emit真正的递归逻辑全部集中在emit这一个方法里。3.2 关键行拆解第一个关键点是构造函数__init__它只接收一个on_leaf回调。这个回调会在每次遇到叶子节点时被调用参数是路径和值。这种设计让 Jev 自己完全不关心业务逻辑校验、输出、统计都由外部控制。第二个关键点是emit方法里的第一个if分支如果当前值是Mapping就遍历它的items把每个键拼接到路径后面然后递归调用emit。注意这里用f{path}.{key}如果key本身是整数也会被自动转成字符串不会报错。第三个关键点是对列表的处理for idx, item in enumerate(value)得到下标和元素路径拼接成path[idx]。这里用enumerate而不是range(len(value))代码更短也更符合 Python 习惯。第四个关键点是最后的else分支一旦走到这里说明当前值既不是字典也不是列表那它就是叶子节点。此时把路径和值交给回调函数整个递归过程在这里结束。3.3 真实场景调用配置审计和数据校验假设我有一个配置快照config.json内容如下{ server: { host: 127.0.0.1, ports: [8080, 9090], options: { debug: false } }, features: [login, report] }用 Jev 遍历它lev Jev(lambda path, value: print(f{path} {value})) with open(config.json, encodingutf-8) as f: jev.load(f.read())输出会是这样$.server.host 127.0.0.1 $.server.ports[0] 8080 $.server.ports[1] 9090 $.server.options.debug False $.features[0] login $.features[1] report如果我要校验某个字段是否存在也很简单expected {$.server.host, $.server.ports[0]} found [] jev Jev(lambda path, value: found.append(path)) jev.load(open(config.json, encodingutf-8).read()) missing expected - set(found) print(missing)这种用法特别适合写在 CI 脚本里几行代码就能完成配置级检查不需要额外安装任何第三方库。4. 踩坑记录和排查思路4.1 字符串是 Sequence 的隐藏坑我第一次写这个工具时没有排除字符串结果遍历到login这种值时它被当成了列表输出变成了$.features[0][0] l。找了一分钟才反应过来str在 Python 里也是Sequence。这个坑其实很常见不止 Jev任何手写递归遍历树形数据的人都可能碰到。解决办法就是判断里加not isinstance(value, (str, bytes))。如果你还要兼容 Python 2还得额外处理unicode不过现在基本不用考虑。4.2 键名规范与路径歧义JSON 的键名如果在末尾加注释时出现点号、方括号、空格等特殊字符Jev 生成的路径会变得有歧义。比如{a.b: 1}输出$.a.b 1你很难分辨它到底是根节点下的a.b键还是a下的b键。我在实际使用中遇到过一次当时排查了很久才发现是键名里带点号。解决方案有三种一是业务上约定配置键名禁止包含点号和方括号二是在 Jev 里对特殊字符做转义但这会增加实现复杂度三是接受这种歧义因为 Jev 主要面向机器处理路径只是标识符不是查询表达式。如果你需要在路径上做精确查询还是上 JSONPath 更靠谱。4.3 递归深度、性能和内存Jev 用递归实现Python 默认递归深度限制是 1000 层。普通 JSON 配置很少会超过这个深度但如果你在处理极端嵌套的数据还是有触顶的可能。此时可以改用sys.setrecursionlimit提高限制或者换成显式栈的迭代版。迭代版代码会长一些但不会遇到递归深度问题。性能方面Jev 的时间复杂度是 O(n)n 是节点总数遍历过程中每个节点只访问一次。因为回调函数本身承担了主要工作Jev 自身的开销其实很小。如果你要遍历非常大的 JSON我建议在回调函数里减少不必要的日志输出否则 I/O 会成为瓶颈。内存方面Jev 没有缓存整棵树的路径只维护当前递归路径字符串所以额外内存占用很低。5. 从 25 行到生产级还能怎么玩5.1 增加过滤器如果写一个真正要交给团队使用的版本我做的第一步就是加过滤器。可以给emit方法增加一个include参数或者让回调返回布尔值来决定是否继续往下遍历。比如我只关心路径以$.server开头的节点可以在回调里判断但更高效的做法是在递归时直接把不符合条件的子树剪掉。Jev 当前的实现不剪枝这是为了保持简单但扩展成剪枝版并不困难。举例来说我希望只遍历数组中下标为偶数的节点可以在emit的列表分支加一个条件判断elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)): for idx, item in enumerate(value): if idx % 2 0: self.emit(f{path}[{idx}], item)这种改动没有破坏原有结构只是让遍历过程更智能。5.2 对接类型校验Jev 很适合做 JSON Schema 的轻量补充。比如我想确保每个叶节点的类型符合预期可以在回调里检查type(value).__name__type_map { $.server.ports[0]: int, $.server.host: str, } def check(path, value): if path in type_map and type(value).__name__ ! type_map[path]: print(f类型不匹配: {path} 期望 {type_map[path]}实际 {type(value).__name__}) jev Jev(check)虽然不如完整 JSON Schema 强大但对于简单配置校验这种“路径 类型”的字典映射方式非常直观别人接手也容易理解。5.3 发布成小包的几个细节如果你决定把 Jev 发布成内部 pip 包有几个细节值得注意。第一加一个顶层函数而不是只给类比如walk_json(obj, on_leaf)调用方不需要知道类名。第二加上类型注解和 docstring虽然会让代码超过 25 行但可维护性会好很多。第三补上单元测试。Jev 的测试重点有三个空结构、只有字典没有列表、只有列表没有字典。这几个 case 看着简单但最容易暴露边界问题。发布的版本不用再刻意追求 25 行这个数字因为“25 行”只是初版的设计约束生产代码更看重清晰和稳定。Jev 的价值在于用最短路径验证了“递归遍历 JSON 并回调”这个思路后续扩展完全可以在同一个骨架上慢慢加肉。我个人在实际使用中的体会是这种 25 行的迷你工具虽然不够“企业级”但它让我对递归、抽象基类、回调函数三个知识点都有了更直观的理解。遇到类似需求时我已经不太需要去翻第三方库文档直接写一个小类就能解决。如果你也想练手建议先从这种“限制行数”的小项目开始砍掉不必要功能后你反而会更清楚哪些代码是真正不可省略的。