JSON 是一个很小的数据格式,标点规则严格,边界情况的代价却出人意料地高。搞清楚它能表示的六种值,在系统边界上做校验,别再假装日期和超大整数是 JSON 的原生类型。
🎙️ 发布并录制于: ·
JSON 能装的东西只有对象、数组、字符串、数字、布尔值和 null。整个类型系统就这些。没有日期,没有字节数组,没有集合,没有注释,没有 undefined,也没有专门的整数类型。对象把字符串键映射到值,数组保留顺序。对象里键的先后顺序永远不该承载含义,哪怕很多 parser 恰好会保留它。
{
"name": "Mina",
"score": 9.5,
"active": true,
"middle_name": null,
"skills": ["SQL", "Python"],
"address": { "city": "Leeds" }
}{"middle_name": null} 是明确给出了这个键,值为空。{} 是根本没有这个键。很多 API 用这个区别来表达「清空这个字段」和「保持原样」。含义要写进你的接口约定,别让每个客户端自己猜。
键和字符串必须用双引号。成员之间用逗号分隔,但结尾多一个逗号是非法的。注释也是非法的。数字不能是 NaN 或 Infinity。JSON 是一种传输格式,不是给人天天手写的配置语言。如果这个文件每天都有人改,就用 TOML 或 YAML,再配上校验,别自己发明一种带注释的 JSON。
// Invalid JSON
{ 'port': 3000, "debug": true, }
// Valid JSON
{ "port": 3000, "debug": true }Chrome 可能报 SyntaxError: Unexpected token ' in JSON at position 2。第一步:看报错位置上的那个字符。第二步:把单引号的键和字符串换成双引号。第三步:删掉结尾逗号和注释。第四步:跑一下 python -m json.tool settings.json。成功时它打印格式化后的 JSON,失败时给出准确的行号和列号。
子对象真的属于父对象时,嵌套才有意义。收货地址就该放在订单快照里面。五十层泛泛的 data、attributes 和 items 不会让 API 更灵活,只会逼每个调用方都写一堆防御代码。稳定的标识符放在靠上的位置,只有当重复出现的值真的有顺序时才用数组。
{
"order_id": "ord_204",
"customer_id": "cus_18",
"shipping_address": {
"line1": "14 King Street", "city": "Leeds"
},
"items": [
{ "sku": "BK-7", "quantity": 2 }
]
}约定允许字段缺失时,访问嵌套数据要写得防御一点。在 JavaScript 里,order.shipping_address?.city ?? "unknown" 能兜住对象不存在的情况。但这不能替代一份写清楚的结构说明。把示例和 schema 发布出来,客户端才知道哪些键是必填的。
解析是把 JSON 文本变成语言里的值,serialization 是反过来。这条边界要一直看得见。JavaScript 用 JSON.parse 和 JSON.stringify。Python 里字符串用 json.loads,文件用 json.load,输出用对应的 dump 函数。永远不要靠拼字符串来生成 JSON,转义早晚会背叛你。
// JavaScript
const user = JSON.parse('{"name":"Mina","active":true}');
const text = JSON.stringify(user, null, 2);
# Python
import json
user = json.loads('{"name":"Mina","active":true}')
text = json.dumps(user, indent=2, ensure_ascii=False)遇到 {'name':'Mina'},Python 会报 json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)。第一步:确认输入是 JSON,而不是打印出来的 Python 字典。第二步:在生产方改引号,别在这边做无脑字符串替换。第三步:用 python -m json.tool 验一遍。对象自己指回自己时,JavaScript 会报 TypeError: Converting circular structure to JSON。第一步:看报错里点出的那个环。第二步:显式构造一个普通对象再序列化,或者把这条反向引用换成一个 ID。第三步:除非丢掉这个字段本来就写进了约定,否则别用 replacer 把环藏起来。
合法的 JSON 也可能是没用的数据。数量写成负数、邮箱那个键拼错了,parser 都会收下,因为两种写法语法上都合法。JSON Schema 描述的是必填字段、类型、格式、取值范围,以及允不允许出现未知属性。每一条信任边界上都要校验。进程内部就用正常的类型化模型,别对同一个对象反复校验个没完。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["sku", "quantity"],
"properties": {
"sku": { "type": "string", "minLength": 1 },
"quantity": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}对小的请求对象,我的默认选择是 additionalProperties: false。悄无声息的拼写错误,比一个明确的 400 响应更糟。对外的带版本 API 则要兼容地加字段,同时让客户端容忍自己不认识的字段。这两个目标是冲突的,所以这个开关要想清楚再定,别照抄别人的 schema。
一份普通的 JSON 文档是一个完整的值。上百万条记录的数组通常必须整体解析,想往后追加也很别扭。JSON Lines,也叫 JSONL 或 NDJSON,每行存一个完整的 JSON 值。它更适合日志、导出、pipeline 和增量处理。它不是一份合法的普通 JSON 文档,所以格式要老实标出来。
# users.jsonl
{"id":1,"name":"Mina"}
{"id":2,"name":"Luis"}
{"id":3,"name":"Asha"}
# Process one record at a time
with open("users.jsonl", encoding="utf-8") as rows:
for line in rows:
user = json.loads(line)必须完整的设置对象或 API 响应用 .json。记录彼此独立,而你想追加、流式读、切分,或者遇到一行坏数据还能接着往下恢复,就用 .jsonl。别给 JSONL 的行加逗号,也别套上方括号。那样它就变回一个数组,流式处理的好处也没了。
发 JSON 时带上 Content-Type: application/json。服务端能返回多种格式时,用 Accept: application/json。解析响应体之前先看 HTTP 状态码,因为中间的代理可能返回一个 HTML 错误页。解析成功不代表业务成功,状态码和响应字段仍然算数。
curl --fail-with-body https://api.example.com/orders \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data '{"sku":"BK-7","quantity":2}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{"order_id":"ord_204","status":"accepted"}SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON 一般说明响应其实是 HTML。第一步:把状态码和收到的 Content-Type 打出来。第二步:看原始响应体。第三步:修掉产生这个 HTML 页面的 URL、认证或代理问题。第四步:只有响应约定说是 JSON 时才去解析 JSON。别把那个尖括号切掉再喂给 parser。
JSON 的数字没有承诺位宽,但 JavaScript 的数字超过九千万亿之后就会丢整数精度。标识符不参与算术,所以大 ID 要用字符串发。JSON 也没有日期类型。发一个清楚的 ISO 8601 时间戳,带上时区,最好是以 Z 结尾的 UTC。像生日这种只有日期的值,就保持成只有日期的字符串。
{
"order_id": "9223372036854775807",
"created_at": "2026-07-25T14:05:00Z",
"birthday": "1994-03-12",
"amount_minor": 1299,
"currency": "GBP"
}涉及钱的时候,如果这个货币有惯用的最小单位,就发整数的最小单位加上货币代码。需要任意小数精度时,发十进制字符串,并且把小数位数写进文档。绝对不要发 2026-07-25 09:00 这种不带偏移量的本地时间。两个客户端可以把它解析成两个不同的时刻,而且都觉得自己是对的。
解析 JSON 比执行代码安全,但解析出来的数据仍然由发送方控制。限制请求体大小和嵌套深度,校验 schema,对请求的动作做鉴权,并且在用到值的地方按目标环境做转义。JSON 挡不住 SQL 注入、路径穿越、XSS,也挡不住 prototype pollution。
// Good boundary order
limitBody("256kb");
const value = JSON.parse(rawText);
validateSchema(value);
authorize(request.user, value.order_id);
await db.query("SELECT * FROM orders WHERE id = $1", [value.order_id]);
// Never do this
eval("(" + rawText + ")");
Object.assign(globalDefaults, value);超大的请求体要在缓冲之前就拒掉。合并对象之前要拒掉预期之外的键,JavaScript 系统里尤其是 __proto__、constructor 和 prototype。数据库查询一律参数化。渲染到 HTML 时对输出做转义。写日志前把密码、令牌和个人信息脱敏。该问的问题不是「JSON.parse 收下了吗」,而是「这个值在这里被允许吗」。
把这份速查表放在负责边界的那段代码旁边。
# six values
object {} array [] string "text"
number 12.5 boolean true false null null
# strict rules
double quotes · no trailing comma · no comments · UTF-8
no undefined · no NaN or Infinity · keys are strings
# JavaScript
JSON.parse(text) JSON.stringify(value, null, 2)
# Python
json.loads(text) json.dumps(value, indent=2)
python -m json.tool file.json
# HTTP and modeling
Content-Type: application/json
large ID → string timestamp → ISO 8601 with zone
stream of records → JSONL untrusted input → limit + validate我的看法很简单:JSON 在边界上应该是无聊的。ID 用字符串,时间戳写明确,进来的数据配 schema,记录流用 JSONL。如果你的格式需要注释、引用、自定义日期字面量,再加五套解码约定,那它已经不是一份简单的 JSON 约定了。换一个格式,承认数据本来是什么样子。