JSON 与 YAML 处理深入:解析器原理、Schema 校验与工程陷阱

系统覆盖 JSON 与 YAML 两大文本数据格式的工程处理:解析器内部原理(词法/语法/递归下降)、流式解析与内存边界、序列化陷阱(浮点/时间/键序)、Schema 校验(JSON Schema/类型契约)、YAML 锚点别名与合并键、格式互转、安全(原型污染/zip 炸弹/别名炸弹)与性能优化。

引言

JSON 与 YAML 是当今配置与数据的「通用语」,但绝大多数工程问题都出在对格式的想当然:JSON 里浮点与时间被悄悄改写、YAML 的隐式类型把 on 变成布尔、流式解析与内存爆炸、Schema 校验缺失导致的配置漂移。本文把这两种格式从「会用」讲到「懂原理」:先讲解析器内部(词法 → 语法 → 值树)与递归下降的实现,再讲流式解析与内存边界,接着逐个拆解序列化陷阱(浮点/时间/键序/重复键),然后讲 JSON Schema 校验与类型契约、YAML 锚点/别名/合并键、格式互转与选型,最后给安全清单(原型污染/炸弹输入)与性能优化,让你在生产里把配置和数据「玩得明白」。

前置:/serialization-formats-compare/(格式全景对比)、/dsl-design/(解析器与文法)、/unicode-encoding-guide/(字符编码)。正则基础见 /regex-deep-dive/。


目录


1. JSON 与 YAML:配置格式的两极

两种格式的哲学对照:

JSON:
  机器友好、严格、无注释、标准单一(RFC 8259)
  → 数据交换(API、存储、跨语言)
YAML:
  人友好、宽松、有注释、方言众多(1.1/1.2/各家实现)
  → 配置描述(K8s、CI、Ansible、docker-compose)
维度JSONYAML
表达能力6 种类型JSON 超集 + 锚点/别名/合并键/多文档
可读性结构化但啰嗦缩进即结构,注释友好
严格度严格(语法单一)宽松(隐式类型/多方言)
解析成本低、稳定高、实现差异大
适用程序间数据人类写的配置

关键判断:YAML 是 JSON 的超集(JSON 语法在 YAML 里合法),但 YAML 的宽松换来的是类型魔法与方言分裂——这是后面所有坑的根源。

心智:JSON 是「机器写给机器读」,YAML 是「人写给机器读」——交换用 JSON,配置用 YAML,这是默认答案。


2. 解析器原理:从文本到值树

任何解析器的三段论:

词法分析(Lexer)→ 标记流 → 语法分析(Parser)→ 抽象值树
文本输入                词法记号                内存中的结构化值

JSON 词法:只有 6 种记号

# 记号类型(示意)
TOKENS = {
    '{', '}', '[', ']', ':', ',',
    'STRING', 'NUMBER', 'TRUE', 'FALSE', 'NULL',
}

递归下降的骨架——每个语法结构一个函数,值类型用「先行记号」分派:

class JSONParser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0

    def parse_value(self):
        t = self.tokens[self.pos]
        if t.type == '{': return self.parse_object()
        if t.type == '[': return self.parse_array()
        if t.type == 'STRING': return self.parse_string()
        if t.type == 'NUMBER': return self.parse_number()
        if t.type == 'TRUE':  return True
        if t.type == 'FALSE': return False
        if t.type == 'NULL':  return None
        raise SyntaxError(f'意外的记号 {t}')

    def parse_object(self):
        self.expect('{'); obj = {}
        if self.peek('}'): self.expect('}'); return obj
        while True:
            key = self.parse_string()
            self.expect(':')
            obj[key] = self.parse_value()
            if self.peek('}'): break
            self.expect(',')
        self.expect('}')
        return obj

为什么递归下降是主流:文法简单、代码直观、错误信息可定制、可加位置追踪。JSON 的 LL(1) 文法完美适配;YAML 因缩进敏感与隐式类型,主流实现(如 libyaml、ruamel)改用事件驱动的字符流状态机而非纯递归下降。

解析的正确性基准:

- 拒绝非法输入(尾随逗号、单引号、裸标识符)
- 数字边界(NaN/Infinity/前导零/超大精度)
- Unicode 转义(\uD83D\uDE00 代理对、未配对代理)
- 深度限制(防栈溢出:超深嵌套直接报错)

心智:解析器 = 词法(切词)+ 递归下降(组树)——JSON 简单到可以手写,YAML 复杂到值得用成熟库。


3. 递归下降手写解析器

一个完整可跑的最小 JSON 解析器(字符串 + 数字 + 对象 + 数组):

import re

def parse_json(text):
    i = 0
    n = len(text)

    def skip_ws():
        nonlocal i
        while i < n and text[i] in ' \t\n\r': i += 1

    def parse_value():
        nonlocal i
        skip_ws()
        if text[i] == '{': return parse_object()
        if text[i] == '[': return parse_array()
        if text[i] == '"': return parse_string()
        if text[i:].startswith('true'): i += 4; return True
        if text[i:].startswith('false'): i += 5; return False
        if text[i:].startswith('null'): i += 4; return None
        return parse_number()

    def parse_object():
        nonlocal i
        i += 1  # {
        obj = {}
        skip_ws()
        if text[i] == '}': i += 1; return obj
        while True:
            skip_ws()
            key = parse_string()
            skip_ws(); i += 1  # :
            obj[key] = parse_value()
            skip_ws()
            if text[i] == ',': i += 1; continue
            if text[i] == '}': i += 1; return obj
            raise ValueError('对象内预期 , 或 }')

    def parse_array():
        nonlocal i
        i += 1  # [
        arr = []
        skip_ws()
        if text[i] == ']': i += 1; return arr
        while True:
            arr.append(parse_value())
            skip_ws()
            if text[i] == ',': i += 1; continue
            if text[i] == ']': i += 1; return arr
            raise ValueError('数组内预期 , 或 ]')

    def parse_string():
        nonlocal i
        i += 1  # "
        out = []
        while i < n:
            c = text[i]; i += 1
            if c == '"': return ''.join(out)
            if c == '\\':
                e = text[i]; i += 1
                out.append({'n':'\n','t':'\t','"':'"','\\':'\\','/':'/',
                            'b':'\b','f':'\f'}.get(e, e))
            else:
                out.append(c)
        raise ValueError('未闭合字符串')

    def parse_number():
        nonlocal i
        m = re.match(r'-?\d+(\.\d+)?([eE][+-]?\d+)?', text[i:])
        if not m: raise ValueError(f'非法数字: {text[i:20]}')
        i += m.end()
        return float(m.group(0)) if '.' in m.group(0) or 'e' in m.group(0).lower() \
               else int(m.group(0))

    return parse_value()

这个实现的生产缺口(值得注意):

- 未校验 `\uXXXX` 转义与代理对
- 数字精度依赖 float(大整数会丢精度 → 用 int/decimal)
- 无深度限制(超深输入栈溢出 → 加 max_depth)
- 错误无行列号(生产解析器要带位置追踪)

心智:手写解析器是理解「词法+递归下降」的最佳练习,生产环境则用成熟库——把精力留给 Schema 与安全。


4. 流式解析:内存边界与超大规模

把整个文档读进内存再解析 = 内存 O(n)。对超大 JSON(日志流、数据导出、百 MB 配置文件)需要流式解析。

两种流式方案:

1. SAX 式回调(事件流):
   边读边触发事件 → start_object / key / value / end_object
   → 内存 O(深度),无法做任意跳转
2. 增量解析(Partial JSON):
   传入字节块 → 返回「已完成的子树」+ 待续状态
   → 适合流式响应(LLM 输出、网络流)
# Python: 事件流式解析(ijson 风格)
import ijson

# 逐个对象处理,不一次性载入
for item in ijson.items(open('huge.json', 'rb'), 'results.item'):
    process(item)          # 内存峰值 ≈ 单个 item

# 手写生成器式增量解析(示意)
def read_tokens(stream):
    buf = ''
    while True:
        chunk = stream.read(64 * 1024)
        if not chunk: break
        buf += chunk
        # 解析可消费的部分,把未完整 token 留给下一轮
        while True:
            tok = try_parse_one(buf)
            if tok is None: break     # 需要更多字节
            yield tok

流式解析的权衡:

流式:内存低、延迟低、无法整树操作(不能按路径随机查)
全量:内存高、可任意操作、实现简单
→ 决策依据:数据规模是否超过「内存预算的一小部分」

工程要点:

- 超大文件优先流式(日志管道天然流式)
- 需要跨记录聚合(排序/去重)时反而全量更简单
- 流式方案对错误恢复更友好(坏记录跳过继续)

心智:「内存 vs 能力」的抉择——数据大就用流式,能力需求高就用全量,先评估再决定。


5. 序列化陷阱:浮点、时间与键序

同一个对象,序列化后再反序列化,可能已经不是同一个对象:

陷阱现象规避
浮点精度0.1 → 0.1 往返丢失(二进制表示)用十进制字符串/定点类型
时间格式时间戳被转成不同时区表示约定 ISO 8601 + UTC
键顺序字典顺序被打乱/保留明确「键序是否语义」
重复键后值覆盖前值解析器报错或显式策略
NaN/Infinity非标准 JSON 非法值序列化时拦截/转 null
大整数JS 侧丢精度(>2⁵³)转字符串/Decimal
不可序列化类型Set/Date/函数自定义序列化钩子
# Python: 保留大整数精度
from decimal import Decimal
import json

data = {'big': Decimal('9007199254740993')}
print(json.dumps(data))            # 报错(Decimal 不可序列化)
print(json.dumps(data, default=str))  # '{"big": "9007199254740993"}'

# Go: json 对大数会退化成 float64 → 用 json.Number
dec := json.NewDecoder(r)
dec.UseNumber()                    // 保持原始数字字符串

时间的规范:永远用 ISO 8601 + 显式时区(2026-09-28T10:00:00+08:00),避免隐式本地时区。

错误:{"created": "2026-09-28 10:00"}      ← 无时区
正确:{"created": "2026-09-28T10:00:00+08:00"}

键序要不要保留:不要依赖。不同语言、不同实现(dict/sorted/insertion)行为不一;需要顺序的业务用数组套 {key, value}。

心智:序列化是「有损通道」——浮点、时间、键序、大整数四大坑要在写序列化代码前就想清楚。


6. Schema 校验:把配置变成契约

没有 Schema 的配置 = 运行时才发现的错误。Schema 校验把「字符串配置」变成「编译期契约」:

- 类型契约:字段必须是 number/string/bool/object/array/enum
- 必填约束:required、min/max、pattern、uniqueItems
- 嵌套校验:对象内的对象、数组内的元素
- 语义校验:conditional(if-then-else)、anyOf/oneOf

JSON Schema 实战:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["name", "endpoints", "timeout"],
  "properties": {
    "name": { "type": "string", "minLength": 1, "maxLength": 64 },
    "endpoints": {
      "type": "array",
      "minItems": 1,
      "uniqueItems": true,
      "items": { "type": "string", "format": "uri" }
    },
    "timeout": { "type": "number", "minimum": 1, "maximum": 300 },
    "env": { "enum": ["dev", "staging", "prod"] }
  }
}

校验的工程位置:

配置入口校验(启动时失败得快)
     ↓
API 请求/响应校验(契约先行,防上下游漂移)
     ↓
数据管道校验(坏数据早拦截,别进存储)

语言侧的类型安全替代:

JSON Schema   → 跨语言、独立于代码(适合配置与外部契约)
Zod/Valibot   → TS 类型 + 运行时校验合一(适合代码内)
pydantic      → Python 类型注解 + 校验(适合服务内部)
→ 选型:配置用 JSON Schema,代码内数据用语言生态的运行时校验

心智:Schema 校验 = 给数据立契约——启动时校验配置、入口校验请求、管道校验数据,把错误拦截在最早处。


7. YAML 高级:锚点、别名与合并键

YAML 的三件「代码复用」利器——这是 YAML 相对 JSON 的核心增量能力:

# 锚点(&)+ 别名(*)+ 合并键(<<)
defaults: &defaults
  replicas: 3
  image: nginx:1.25
  env: production

api-server: &api-server
  <<: *defaults            # 合并 defaults 的全部键
  name: api
  ports: ["8080"]

worker:
  <<: *defaults
  name: worker
  replicas: 5              # 覆盖 defaults.replicas

语义要点:

- &name 定义锚点,*name 引用(浅引用,共享子结构)
- << 是合并键:把锚点的键并入当前映射
- 显式键优先于合并键(worker.replicas 覆盖 defaults.replicas)
- 别名是「引用同一结构」,不是复制——修改会互相影响(在可变实现中)

别名炸弹(Billion Laughs)——YAML 的经典 DoS:

a: &a ["x","x","x","x","x","x","x","x","x","x"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]
# c 展开后 ≈ 10³ 个元素 → 内存爆炸

防御:解析器限制别名展开总量/深度(PyYAML 默认不限制,ruamel/SafeLoader 需配置 max 展开计数)。

隐式类型的坑:

value1: on        # YAML 1.1 中 → true(布尔魔法)
value2: 2026-01-01 # → 日期对象(不是字符串)
value3: 007       # → 整数 7(前导零被吞)
value4: 1.0       # → 浮点(数字不是字符串)

防御:期望字符串时显式引号("on"、"007"),或解析器用 YAML 1.2 规范(on 不再是布尔)。

心智:锚点/别名是 YAML 的复用利器、别名炸弹是安全雷区、隐式类型是魔法——配置要「显式优先」。


8. JSON 与 YAML 互转与选型

互转的坑(JSON ↔ YAML 不是无损的):

JSON → YAML:
  - 数字/布尔/时间被 YAML 隐式类型「魔法化」
  - 键序/重复键风险
  - 大数字变科学计数法/丢失精度
YAML → JSON:
  - 锚点/别名/合并键被「摊平」(展开成实际值)
  - 多文档(--- 分隔)无法直接转单个 JSON
  - 隐式类型要「显式化」(on → "on"?还是 true?)

互转正确姿势:先 load → 转成语言原生值 → 再 dump,让语言层保证类型保真;别用文本级替换。

# yq 是 YAML/JSON 互转与查询的利器
yq eval -o=json config.yaml > config.json   # YAML → JSON
yq eval -o=yaml config.json > config.yaml   # JSON → YAML
yq '.services.web.ports' docker-compose.yml # 查询

选型决策树:

需要注释? ──是──→ YAML(配置)
需要严格机器交换? ──是──→ JSON
需要流式/超大? ──是──→ JSON(流式生态更成熟)
安全敏感输入? ──是──→ JSON(无别名炸弹/无隐式类型)
需要 Schema 契约? ──是──→ JSON Schema(YAML 也可套用)

一个现实的混合方案:配置用 YAML 写(人友好),交付用 JSON(契约严格),中间经 Schema 校验。

心智:JSON 与 YAML 不是竞争而是分工——人写配置用 YAML、机器交换用 JSON,转换永远经过「语言原生值」而不是文本。


9. 安全清单:炸弹输入与原型污染

解析不可信输入 = 默认不安全。三类经典攻击:

攻击原理防御
深度炸弹超深嵌套 → 递归栈溢出限制 max_depth
别名炸弹YAML 锚点指数展开限制展开总量/禁用别名
原型污染__proto__ 键污染对象原型禁止危险键/纯数据模式
键轰炸海量键 → 内存/CPU 耗尽限键数/限量
后门键控制流注入(如 __class__)白名单 Schema
# Python: 原型污染风险演示
import json

payload = '{"__proto__": {"isAdmin": true}}'
# json.loads 默认不污染(Python dict 无原型链),但:
# 某些「对象映射」库(把 JSON 键映射到类属性)会中招

# Go: 用 json.Decoder 限制
dec := json.NewDecoder(r)
dec.UseNumber()

# 统一防御:解析后过白名单 Schema,拒绝未知键

安全基线:

1. 不可信输入一律用「安全加载器」:
   PyYAML → yaml.safe_load(不用 yaml.load)
   其他语言 → 纯数据模式/禁对象构造
2. 设深度与规模上限(max_depth / max_items)
3. 解析后过白名单 Schema(unknown keys 拒绝)
4. 日志/配置来源打标(本地可信 vs 远端不可信)

心智:「输入是不可信的」是铁律——安全加载器 + 深度限制 + 白名单 Schema,三层防住 JSON/YAML 的经典炸弹。


10. 速查表与一句话记忆

全篇速查:

主题结论
定位JSON 机器交换、YAML 人类配置
解析词法 + 递归下降,YAML 用事件状态机
流式数据大用流式(内存 O(深度))
序列化浮点/时间/键序/大整数四坑
Schema启动校验配置、入口校验请求、管道校验数据
YAML 高级锚点复用、别名炸弹、隐式类型魔法
互转经语言原生值,别做文本替换
安全safe_load + 深度限制 + 白名单
工具yq 查询互转、JSON Schema 校验器
选型注释要 YAML、交换要 JSON、契约要 Schema

一句话记忆:JSON 与 YAML 的分工是「机器交换 vs 人类配置」——解析器本质是「词法切词 + 递归下降组树」,数据大就流式、要契约就 Schema;序列化有浮点/时间/键序/大整数四坑,YAML 有锚点复用与别名炸弹,不可信输入永远 safe_load + 深度限制 + 白名单,转换永远经语言原生值而不是文本替换。


延伸阅读

  • /serialization-formats-compare/ — 文本族与二进制族序列化格式全景对比
  • /dsl-design/ — 解析器构建与文法设计的系统方法
  • /unicode-encoding-guide/ — JSON 转义与字符编码底层
  • /regex-deep-dive/ — 解析器词法阶段的正则引擎基础
  • /others-data-compression-guide/ — 大数据量传输时的压缩前置
  • 文本处理工具链 — jq/yq 命令行处理 JSON

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. Markdown 与文档工程:写作规范、静态生成与 LaTeX 排版
  2. 终端与 Shell 生态进阶:zsh、tmux 与高效命令行工作流
  3. 概率统计基础实战:贝叶斯、随机变量、分布与推断