引言
JSON 与 YAML 是当今配置与数据的「通用语」,但绝大多数工程问题都出在对格式的想当然:JSON 里浮点与时间被悄悄改写、YAML 的隐式类型把 on 变成布尔、流式解析与内存爆炸、Schema 校验缺失导致的配置漂移。本文把这两种格式从「会用」讲到「懂原理」:先讲解析器内部(词法 → 语法 → 值树)与递归下降的实现,再讲流式解析与内存边界,接着逐个拆解序列化陷阱(浮点/时间/键序/重复键),然后讲 JSON Schema 校验与类型契约、YAML 锚点/别名/合并键、格式互转与选型,最后给安全清单(原型污染/炸弹输入)与性能优化,让你在生产里把配置和数据「玩得明白」。
前置:/serialization-formats-compare/(格式全景对比)、/dsl-design/(解析器与文法)、/unicode-encoding-guide/(字符编码)。正则基础见 /regex-deep-dive/。
目录
- 1. JSON 与 YAML:配置格式的两极
- 2. 解析器原理:从文本到值树
- 3. 递归下降手写解析器
- 4. 流式解析:内存边界与超大规模
- 5. 序列化陷阱:浮点、时间与键序
- 6. Schema 校验:把配置变成契约
- 7. YAML 高级:锚点、别名与合并键
- 8. JSON 与 YAML 互转与选型
- 9. 安全清单:炸弹输入与原型污染
- 10. 速查表与一句话记忆
- 延伸阅读
1. JSON 与 YAML:配置格式的两极
两种格式的哲学对照:
JSON:
机器友好、严格、无注释、标准单一(RFC 8259)
→ 数据交换(API、存储、跨语言)
YAML:
人友好、宽松、有注释、方言众多(1.1/1.2/各家实现)
→ 配置描述(K8s、CI、Ansible、docker-compose)
| 维度 | JSON | YAML |
|---|---|---|
| 表达能力 | 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。