YAML 不是"更漂亮的 JSON"
常见描述——"YAML 对人友好,JSON 对机器友好"——掩盖了真正的区别。YAML 是一个 80 多页的规范,具有隐式类型强制、锚点、标签、合并键和多文档支持。JSON 是一个 1 页的规范,没有歧义。
这个区别很重要,因为 YAML 的复杂性造成了 JSON 中不存在的类型强制陷阱、安全漏洞和有损转换边界。
挪威问题:隐式类型强制
YAML 最臭名昭著的设计缺陷:裸值基于模式匹配被隐式强制转换为类型化值。
布尔地狱
在 YAML 1.1(直到最近大多数工具仍在使用)中,以下值全部被解析为布尔值 true:
# 这些在 YAML 1.1 中全部变成布尔值
country_code: NO # 挪威 ISO 代码 → false(!)
answer: yes # → true
enabled: on # → true
flag: TRUE # → true
value: y # → true
"挪威问题":一个国家代码列表中 NO(挪威)静默变为 false:
countries:
- DK # 字符串 "DK"
- FI # 字符串 "FI"
- NO # 布尔值 false(!)
- SE # 字符串 "SE"
解析后:["DK", "FI", false, "SE"]
数值强制
version: 1.0 # 浮点数 1.0,非字符串 "1.0"
zipcode: 01234 # 八进制 668 在 YAML 1.1(!),字符串在 1.2
port: 0755 # 八进制 493 在 YAML 1.1(!),字符串在 1.2
time: 12:30 # 六十进制 750 在 YAML 1.1(!),字符串在 1.2
YAML 1.1 vs 1.2:关键差异
| 值 | YAML 1.1 解释 | YAML 1.2 解释 |
|---|---|---|
yes / no |
布尔值 | 字符串 |
on / off |
布尔值 | 字符串 |
y / n |
布尔值 | 字符串 |
0755 |
八进制整数 (493) | 字符串 |
1:30 |
六十进制 (90) | 字符串 |
true / false |
布尔值 | 布尔值 |
null / ~ |
Null | Null |
YAML 1.2 通过将布尔值限制为仅 true/false,整数限制为仅十进制表示法,修复了最严重的强制陷阱。但大多数 YAML 库仍默认使用 YAML 1.1 行为(包括 PyYAML、Ruby 的 Psych 和旧版 js-yaml)。
修复方案:始终引用可能歧义的值
# 安全:显式引用的字符串
country_code: "NO"
version: "1.0"
port: "0755"
enabled: "yes"
安全:YAML 反序列化攻击
YAML 的标签系统允许指定任意类型,在很多语言中意味着任意代码执行。
Python PyYAML:通过标签执行代码
# 危险:使用 yaml.load() 加载时执行 os.system("rm -rf /")
!!python/object/apply:os.system ["rm -rf /"]
import yaml
# 脆弱:yaml.load() 使用默认 Loader 会处理标签
data = yaml.load(malicious_yaml) # 任意代码执行!
# 安全:yaml.safe_load() 忽略自定义标签
data = yaml.safe_load(yaml_string) # 仅基本类型
始终使用 safe_load() / SafeLoader。不带显式 Loader 的 yaml.load() 是最常见的 YAML 漏洞模式。
YAML 炸弹(十亿笑声攻击)
YAML 锚点支持指数级展开:
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]
五层 9× 展开:9⁵ = 59,049 个字符串,仅来自几行代码。这可以耗尽内存并使解析器崩溃。
防御:在解析器配置中设置递归限制和最大展开大小。
JSON 免疫
JSON 没有标签系统、没有锚点、没有在解析过程中执行代码的机制。这是 JSON 简单性的安全优势。
每个转换方向的信息损失
YAML → JSON(信息丢失)
| YAML 特性 | 转换后的行为 |
|---|---|
注释 (#) |
丢失 — JSON 中无表示方式 |
| 锚点和别名 | 展开 为重复数据(增加大小) |
合并键 (<<) |
展开 为扁平对象 |
多文档 (---) |
丢失 — JSON 无多文档概念 |
自定义标签 (!!type) |
丢失 或导致错误 |
多行标量 (|、>) |
转换为带 \n 的单个字符串 |
文档标记 (---、...) |
丢失 |
| 键顺序 | 实现相关 — JSON 规范不保证顺序 |
JSON → YAML(无损,但有选择)
JSON → YAML 是无损的(YAML 是 JSON 的超集)。但转换涉及选择:
- 缩进风格(2 vs 4 空格)
- 流式 vs 块式风格(数组和对象)
- 引号策略(全部引用,还是仅在需要时引用)
- 是否对重复子树使用锚点
解析库安全姿态
| 库 | 语言 | 默认行为 | 安全模式 | YAML 版本 |
|---|---|---|---|---|
| PyYAML | Python | 不安全(标签执行) | safe_load() |
1.1 |
| ruamel.yaml | Python | 往返安全 | 可配置 | 1.2 |
| strictyaml | Python | 安全(无标签、无隐式类型) | 始终安全 | 子集 |
| js-yaml | JavaScript | 安全(默认忽略标签) | 默认 | 1.2(v4 起) |
| go-yaml | Go | 安全(无任意执行) | 默认 | 1.2(v3) |
| SnakeYAML | Java | 不安全(处理标签) | SafeConstructor |
1.1 |
| Jackson YAML | Java | 可配置 | 通过 ObjectMapper | 1.1 |
建议
- Python:配置文件用
strictyaml(消除所有隐式强制),往返编辑用ruamel.yaml,永远不用不带SafeLoader的yaml.load() - JavaScript:
js-yamlv4+ 默认安全 - Go:
gopkg.in/yaml.v3遵循 YAML 1.2,默认安全 - Java:永远不要对不受信任的输入使用
new Yaml().load()— 使用SafeConstructor
何时 TOML 是更好的选择
TOML(Tom's Obvious Minimal Language)专为配置文件设计,避免了 YAML 的复杂性和 JSON 缺少注释的问题:
# TOML:显式类型,无强制转换,支持注释
[server]
host = "localhost"
port = 5432
enabled = true
[database]
name = "myapp"
pool_size = 20
[[routes]]
path = "/api/users"
method = "GET"
[[routes]]
path = "/api/orders"
method = "POST"
YAML vs JSON vs TOML 决策矩阵
| 标准 | YAML | JSON | TOML |
|---|---|---|---|
| 人类可读性 | 好(如果简单) | 中等 | 好 |
| 机器解析速度 | 慢 | 快 | 中等 |
| 注释 | 是 | 否 | 是 |
| 类型安全 | 差(隐式强制) | 好(显式) | 好(显式) |
| 安全风险 | 高(标签、炸弹) | 极低 | 极低 |
| 规范复杂度 | 80+ 页 | 1 页 | ~20 页 |
| 嵌套结构 | 无限深度 | 无限深度 | 超过 3 层时不便 |
| 行业采用 | Kubernetes, CI/CD, Ansible | APIs, package.json | Rust (Cargo.toml), Python (pyproject.toml) |
使用 YAML 当
- 生态系统要求(Kubernetes、GitHub Actions、Ansible)
- 需要多文档文件
- 需要锚点/别名实现 DRY 配置
使用 JSON 当
- 服务间数据交换(API)
- 消费者是 JavaScript/浏览器
- 需要模式验证(JSON Schema 已成熟)
- 安全性至关重要(无代码执行风险)
使用 TOML 当
- 编写应用配置
- 想要注释但不要 YAML 的类型强制风险
- 嵌套浅(≤3 层)
- 生态系统支持(Rust、Python 打包、Hugo)
正确的转换代码
Python(安全)
import json
from ruamel.yaml import YAML
yaml = YAML()
yaml.preserve_quotes = True
# YAML → JSON(安全,YAML 1.2)
with open('config.yaml') as f:
data = yaml.load(f)
json_str = json.dumps(data, indent=2, ensure_ascii=False)
# JSON → YAML
with open('data.json') as f:
data = json.load(f)
with open('output.yaml', 'w') as f:
yaml.dump(data, f)
JavaScript / Node.js
import { load, dump } from 'js-yaml';
import { readFileSync, writeFileSync } from 'fs';
// YAML → JSON
const yamlContent = readFileSync('config.yaml', 'utf8');
const data = load(yamlContent); // js-yaml v4:默认安全
const jsonStr = JSON.stringify(data, null, 2);
// JSON → YAML
const jsonContent = readFileSync('data.json', 'utf8');
const parsed = JSON.parse(jsonContent);
const yamlStr = dump(parsed, { indent: 2, lineWidth: 120 });
Go
package main
import (
"encoding/json"
"gopkg.in/yaml.v3"
)
func yamlToJSON(yamlBytes []byte) ([]byte, error) {
var data interface{}
if err := yaml.Unmarshal(yamlBytes, &data); err != nil {
return nil, err
}
return json.MarshalIndent(data, "", " ")
}
CLI 工具
# yq:YAML 瑞士军刀
yq -o=json config.yaml > config.json
yq -P config.json > config.yaml
# Python 单行命令
python -c "import sys,yaml,json; print(json.dumps(yaml.safe_load(sys.stdin),indent=2))" < config.yaml
生产中的常见陷阱
1. 缩进错误是静默的
YAML 缩进决定结构。一个空格的错误会改变含义:
# 预期:嵌套在 server 下
server:
host: localhost
port: 5432
# Bug:port 是 server 的兄弟节点(缩进错误)
server:
host: localhost
port: 5432
2. Tab vs 空格
YAML 禁止使用 Tab 缩进。一个 Tab 字符会导致解析错误,但很多编辑器将 Tab 和空格显示为相同外观。
3. 看起来像其他类型的未引用字符串
version: 3.10 # 浮点数 3.1(末尾零被丢弃!)
version: "3.10" # 字符串 "3.10"(正确)
4. 多行字符串尾部换行
# | 保留尾部换行
content: |
hello
# |- 去除尾部换行
content: |-
hello
| 和 |- 之间的区别对模板、脚本以及任何尾部换行会改变行为的值都很重要。
总结
YAML 和 JSON 不是语法不同的可互换格式。它们有根本不同的安全模型、类型系统和信息容量。YAML→JSON 方向的转换是有损的(注释、锚点、标签、多文档),JSON→YAML 方向的转换需要做出选择(风格、引号、缩进)。
关键原则:
- 对不受信任的 YAML 始终使用安全解析函数(
safe_load、SafeConstructor) - 引用可能被隐式强制转换的值(
"NO"、"3.10"、"yes") - 优先选择消除布尔强制陷阱的 YAML 1.2 库
- 对于生态系统不强制要求 YAML 的新配置文件,考虑 TOML
- 将 YAML→JSON 转换视为有损操作,并验证输出