JSON格式化工具:美化、压缩和校验JSON数据
JSON 是机器与机器交换数据时的默认选项,它同时也是一种"看起来到处都能用、实际上到处都有边界"的格式。格式化工具能回答一个问题——这段文本语法是否合法;它回答不了另外三个更要命的问题:这个结构在跨语言传递时会不会被悄悄改写?它能不能表达我真正需要的类型?数据量涨上来之后,协议层该做什么取舍?
本文只讲 JSON 的工程边界:哪些数字会在解析时失真、哪些字段会在序列化时凭空消失、为什么同一份文件在不同语言里可能得到不同结果,以及超大 payload 到来时的处理策略。文中所有数字都在 Node 22 上实测,可以照着复现。
一、什么时候 JSON 不是答案:五种格式的选型
选数据格式只看四个问题:谁写、谁读、要不要注释、要不要类型。JSON 在"机器写、机器读"这一格几乎没有对手;一旦写的人变成人,或者需要注释与类型信息,它就开始吃紧。
| 格式 | 注释 | 类型与精度 | 手写友好度 | 典型用途 | 主要代价 |
|---|---|---|---|---|---|
| JSON | 不支持 | 只有字符串、数字、布尔、null、对象、数组;数字精度由实现决定 | 低:强制双引号,禁止尾随逗号 | Web API、消息队列、文档型存储 | 表达力最弱,缺失的部分只能靠约定补 |
| YAML | 支持 | 有日期、二进制、锚点与引用 | 高 | Kubernetes 清单、CI 流水线、人工维护的配置 | 缩进敏感;隐式类型推断会咬人,纯日期会被当成日期类型 |
| XML | 支持 | 全部是文本,类型靠模式文件约束 | 中 | 企业级契约、需要命名空间或混合内容的文档 | 冗余度最高,解析与人力成本都高 |
| TOML | 支持 | 有日期时间、整数、浮点、数组、表 | 高 | 项目配置,如 Cargo、pyproject | 不适合承担深层嵌套的数据交换 |
| CSV | 不支持 | 只有文本,类型完全靠约定 | 中 | 表格数据、报表导出、批量导入 | 没有层级;引号、换行、编码处处是坑 |
规则可以压缩成三句:程序生成、程序消费的数据用 JSON;人写、需要注释、层级不深的配置用 TOML 或 YAML;扁平表格用 CSV,需要命名空间和强模式校验时才回到 XML。JSON 的优势不在"好写",而在"无歧义":任何语言的解析器读同一段文本,都会得到相同的结构。这既是它被接口层选中的原因,也是它刻意放弃注释与类型换来的结果。
二、数字:JSON 唯一的数值类型就叫"数字"
RFC 8259 只规定了数字的语法——可选负号、整数部分、可选小数部分、可选指数——对取值范围和精度没有任何规定,规范第 6 节甚至明确允许各实现自行设定可接受的数字范围与精度。也就是说,"一个 JSON 数字究竟有多大"不是规范问题,而是实现问题:同一段文本交给不同语言的解析器,得到的数值可能不一样。
JavaScript 是最典型的一种实现:JSON.parse 把每个数字都装进 IEEE 754 双精度浮点数。双精度能精确表示的整数上界是 2^53 − 1,也就是 9007199254740991(约 9.007 × 10^15,16 位十进制数字)。越过这条线之后,相邻可表示值之间出现间隔,数字会被静默舍入到最近的可表示值,不报错、不警告。
下表是在 Node 22 上逐条实测的结果:
| 原文中的数字 | 经 JSON.parse 之后 | 是否失真 | 原因 |
|---|---|---|---|
| 9007199254740991 | 9007199254740991 | 否 | 恰好等于 2^53 − 1,安全上界 |
| 9007199254740992 | 9007199254740992 | 否 | 2^53 本身可精确表示 |
| 9007199254740993 | 9007199254740992 | 是,少了 1 | 落在两个可表示值正中,舍入到偶数尾数 |
| 9007199254740995 | 9007199254740996 | 是,多了 1 | 同样规则,这次舍入方向相反 |
| 1234567890123456789 | 1234567890123456800 | 是,末位被改写 | 19 位雪花 ID 的典型失真 |
| 9876543210987654321 | 9876543210987655000 | 是,后 4 位被改写 | 可表示间隔已经扩大到 1024 |
| 99999999999999.99 | 99999999999999.98 | 是,分位少了 1 | 16 位有效数字已超出双精度的十进制精度 |
9007199254740993 这个例子还有一层更隐蔽的后果:失真不只发生在解析时。在 JavaScript 里计算 9007199254740993 - 9007199254740992,结果是 0——因为两个字面量在参与运算之前,就已经是同一个双精度值了。换句话说,你没法在运行时"检测"这次失真,只能在数据进入系统之前就把它挡住。
金额是第二个重灾区,但它的失效方式与整数不同。19.9 这样的十进制小数在二进制里本来就不精确,JSON.parse 给出的确实是"最接近 19.9 的那个双精度数",可是它一旦参与运算就会漂出来:19.9 × 3 = 59.699999999999996,而 0.1 + 0.2 = 0.30000000000000004。业界的通用做法是用最小货币单位的整数承载金额:19.9 元写成 1990 分,乘 3 得 5970,再除以 100 得到 59.70,全程停留在整数域内,不产生任何舍入。
反过来看时间戳,这条边界其实相当宽松。13 位的毫秒时间戳(当前约 1.78 × 10^12)离上界还有三个数量级的余量;16 位的微秒时间戳(约 1.78 × 10^15)也仍在安全区内,按实测推算要到 2255 年才会触顶。真正危险的从来不是时间,而是那些"本质是编码、却被当成数量"的标识符。
由此得到两条硬约定:超过 15 位有效数字的标识符(订单号、雪花 ID、数据库主键)和所有金额,在 JSON 里一律用字符串或整数承载,不要用 JSON 数字。数据库里是 bigint,不等于 JSON 里也能当数字传——中间那一层双精度会替你做出决定,而且不通知你。
三、JSON 没有的三种东西,与工程上的替代方案
JSON 的类型系统只有六种:字符串、数字、布尔、null、对象、数组。工程上高频需要的另外三样——注释、日期,以及"字段根本不存在"这种状态——都必须靠约定表达。
注释:写进数据,还是放在数据之外
标准 JSON 不能有注释,所以注释只有两个去处。一是把说明写成同层的普通字段,惯例是以下划线开头,例如 _comment、_note、_schema。这样做的好处是说明和数据同生共死,复制粘贴不会丢;代价是这些字段会真实存在于数据里,消费端要么显式忽略它们,要么就要面对"多出来的字段算不算契约变更"这个问题。二是把说明放在数据之外:独立的模式文件、接口文档,或者构建期才处理的 JSONC、JSON5。判断标准很简单:这份 JSON 会不会被别的系统原样透传或原样存储?会,就把注释放到外面。至于用双斜杠写注释的写法,它只在你自己的构建链路里成立,一旦这份文件被标准解析器读到,那两个字符就是非法记号。
日期:ISO 8601 字符串,并且带上时区
JSON 没有日期类型,所以日期只能是一种字符串。通行做法是 ISO 8601,并且必须带时区偏移或 Z,例如 2026-09-29T08:30:00Z。实测中 JSON.stringify(new Date()) 输出的正是 2026-09-29T08:30:00.000Z 这种带毫秒的标准 UTC 形式,而把它解析回来得到的是一个字符串,不是日期对象——类型转换的责任始终在应用层。
这里有两个容易忽略的差异。第一是精度:秒级和毫秒级混用会让排序和去重出现意外结果,团队内要统一到毫秒。第二是纯日期字符串:2026-09-29 在 JSON 里就是普通字符串,但在 YAML 里会被隐式推断成日期类型,同一个值跨格式搬运时类型可能突变。需要按"日"而不是按"时刻"表达时,建议显式约定为不含时间的日期字符串,并在模式文件里写清。
null 与缺键:两种完全不同的语义
这是接口设计里最容易含糊的一处。键存在、值为 null,意思是"这个字段被明确置空了";键根本不存在,意思是"这次没有提到这个字段"。两者在数据层面完全可区分——实测中 "nickname" in {"nickname":null} 为 true,而 "nickname" in {} 为 false。
| 写法 | 语义 | 消费端应当如何处理 |
|---|---|---|
{"nickname":"Tom"} | 显式赋值 | 把字段更新为 Tom |
{"nickname":null} | 显式清空 | 按约定置空;在合并补丁类接口中通常表示删除该字段 |
{},不含该键 | 本次未提及 | 保持字段原值不变 |
值来自 JavaScript 的 undefined | 键在序列化时直接消失 | 效果等同"未提及",接收方无法分辨 |
最后一行值得单独说:在 JavaScript 里,对象属性值为 undefined 时,JSON.stringify 会把这个键整个丢掉,而不是写成 null。实测 JSON.stringify({a: undefined, b: 1}) 的结果是 {"b":1};如果同样的值出现在数组里,undefined 又会被写成 null。同一个"空",在对象里消失、在数组里变成 null,而顶层直接序列化 undefined 连字符串都不产出。所以"字段没传上来"和"字段传了但值是 undefined"在下游看起来一模一样,只能靠显式写入 null 来区分。
结论:把 null 与省略写进接口契约,不要留给调用方猜。更新类接口尤其要明确:null 是"清空"还是"删除",省略是"不动"还是"取默认值"。
四、序列化过程中的静默改写清单
除了数字精度,序列化环节还会做一批"不改语义、但改文本"的动作。它们单独看都合理,凑在一起就成了线上问题最难排查的部分:数据在两端看起来都"正常",只是不一样。
| 现象 | 触发条件 | 后果 | 规避方式 |
|---|---|---|---|
| 整数键被提前并升序排列 | 对象的键名是纯数字字符串,例如 {"2":"b","1":"a","name":"x"} | 顺序变成 {"1":"a","2":"b","name":"x"},与书写顺序不符 | 不要依赖键顺序表达业务含义;需要顺序就用数组 |
| 重复键静默取后者 | 同一对象里出现同名字段,例如 {"a":1,"a":2} | 解析结果是 {"a":2},不报错 | 合并配置前先校验,避免"手改一处、覆盖另一处" |
| NaN 与 Infinity 变成 null | 值来自计算,可能是非有限数 | 序列化后写成 null,与真正的空值无法区分 | 先判有限性,非法值用明确标记或直接拒绝写入 |
| 溢出值仍能解析 | 文本里写了 1e999 这种合法但超范围的数字 | 语法合法,解析结果是 Infinity,再序列化又变成 null | 对数值字段做范围校验,不能只看语法 |
| 日期对象变成字符串 | 值来自内置日期类型或日期库 | 写出去是 ISO 8601 字符串,读回来是字符串而不是日期 | 契约里明确日期一律为 ISO 8601 字符串 |
| 文件开头带 BOM 时整段报错 | 文件由部分编辑器或表格软件导出,带 UTF-8 BOM | 解析直接抛错,提示类似 Unexpected token,而文件肉眼完全正常 | 清掉文件头部的不可见字符后再解析 |
| 存在循环引用时直接抛错 | 对象属性指回自身 | 抛出 Converting circular structure to JSON,属于硬失败而非静默改写 | 序列化前断开反向引用,或只挑选需要的字段 |
这份清单里最值得记住的是 BOM 那一条:它是"文件完全正常却解析失败"最常见的成因,而 BOM 恰恰是最难用肉眼发现的一类字符——它和空文件的开头长得没有任何区别。遇到无法解释的解析失败,第一步应该是检查文本头部有没有夹带不可见字符,可以先用文本清洗工具过一遍,再回来格式化。
五、三个可以复现的算例
算例一:从数据库到前端,一个订单号是怎么变的。某订单表主键是 19 位雪花 ID,值 1234567890123456789。接口把它作为 JSON 数字返回,浏览器解析之后这个值变成 1234567890123456800,后两位被改写。用户点开订单详情,前端拿这个 ID 去请求,服务端查不到记录,返回 404;如果服务端做了"就近匹配",还可能返回另一个用户的订单。同一个字段换成 9876543210987654321,失真更明显,后 4 位被改写成 5000。修法只有一条:ID 在 JSON 里始终是字符串,序列化时显式转换,不要依赖默认行为。
算例二:金额计算为什么会差一分钱。商品单价 19.9 元,买 3 件。若 JSON 里写的是数字 19.9,解析后参与乘法得到 59.699999999999996;如果中间又做了四舍五入、累加、再打折,误差会逐步放大,最终对账时出现一分到几分的差额。改成以分为单位的整数:单价 1990,数量 3,小计 5970,折扣按整数比例计算,最后一步才除以 100,全程没有任何浮点误差。涉及税费分摊、优惠券按比例拆分的场景,同理。
算例三:压缩 JSON 到底省了多少。取一个包含 12 条明细的订单对象(含商品名、数量、单价、标签、收货信息),分别用两种形态序列化,再各做一次 gzip:
| 形态 | 原始字节 | gzip 之后 | gzip 压缩率 |
|---|---|---|---|
| 格式化(2 空格缩进) | 2141 | 397 | 18.5% |
| 压缩(不含任何空白) | 1192 | 345 | 28.9% |
| 压缩相对格式化的节省 | 省 44.3% | 只再省 13.1% | — |
结论有点反直觉:在不压缩的通道上,去掉空白能省 44.3%;但在已经启用 gzip 的通道上,同样的操作只能再省 13.1%——397 字节和 345 字节之间只差 52 字节。原因是 gzip 已经把这些规律性极强的空白和重复键名压掉了。换成 5000 行的数组再验证一次:压缩形态 200281 字节,gzip 之后是 24565 字节,只剩 12.3%。所以"为了省流量所以必须压缩 JSON"这个理由,在现代传输链路上收益有限;真正值得优化的是解析后的内存占用与对象数量,而不是原始字节数。
六、大 JSON 该怎么读
先把"大"量化,否则讨论没有意义。上面那段 5000 行、200281 字节的 JSON 数组只有约 196 KB,对任何解析器都不算大;真正的量级线在几十兆到几吉字节。到了那个体量,"一次解析全读进来"会在内存里同时持有原文文本和解析后的对象树,峰值远高于原文字节数,而且这个过程无法回退。
| 策略 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 全量解析 | 一次性读入并解析 | 几十 KB 到几 MB,需要随机访问 | 内存峰值等于全文加对象树 |
| 按行分片 | 每行一个独立 JSON 对象,逐行解析,即 NDJSON 风格 | 日志、批量导出、流式导入 | 无法表达跨行的嵌套文档结构 |
| 事件式流解析 | 遇到一个字段就回调一次,不在内存里建整棵树 | 超大文件里只想抽取少数字段 | 代码复杂度显著上升,不能随机回溯 |
| 分页或游标 | 接口层限制单次返回量,用游标翻页 | 列表类接口 | 需要服务端支持,调用方要处理翻页状态 |
| 字段裁剪 | 只请求需要的字段,从源头减小 payload | 前端只需要少数字段 | 需要接口支持字段选择 |
另外两点实测结论可以帮你排优先级:一是嵌套深度通常不是首要瓶颈,在 Node 22 上构造 10000 层嵌套的数组仍然可以正常解析,体量比深度更早出问题;二是分片比压缩更根本,因为 gzip 只能减少传输字节,减少不了解析后的对象数量。需要逐行处理的场景,可以把数据存成 NDJSON,再配合文本对比工具比较两次导出的差异,比整份文件肉眼核对可靠得多。
七、接口契约里值得固定的六个约定
- 字段命名只选一种,并写进规范:JSON 字段用驼峰还是下划线本身没有对错,混用才是灾难——同一个对象里出现两种写法,消费端就得为每个字段记两遍。跨语言边界时可以用命名风格转换工具批量统一。
- ID 一律字符串:理由见算例一,这条没有例外。
- 金额一律用最小单位整数,并标注币种:
{"amount":5970,"currency":"CNY"}比{"amount":59.7}安全得多。 - 日期一律 ISO 8601 字符串,统一到毫秒:不要混用秒级和毫秒级。
- 错误对象形状固定:给机器判据留一个稳定字段(例如错误码),人类可读的文案单独放,永远不要让调用方去匹配提示文字。
- 二进制不要直接塞进 JSON:Base64 会让体积膨胀约三分之一,大文件应该走独立的二进制通道。相关取舍可以看Base64 编码指南与Base64 编解码工具。
还有两条偏细节的约定:URL 路径习惯用连字符、JSON 的键习惯用驼峰或下划线,两边不要互相传染,URL 里的特殊字符该编码就编码,具体规则见URL 编码工具;字段格式校验(手机号、单号、时间格式)适合先用正则表达式测试器把规则跑通,再落到服务端。
八、常见问题(FAQ)
- JSON 里的数字为什么会自己变?
- 因为 JSON 规范只规定了数字的语法,没有规定精度,而主流实现(最典型的是 JavaScript 的 JSON.parse)用 IEEE 754 双精度承载所有数字。双精度的安全整数上界是 9007199254740991,超过之后就会静默舍入:9007199254740993 会变成 9007199254740992,19 位雪花 ID 会变成末位为零的近似值。解决办法是把这类值当字符串传。
- null 和"字段缺失"到底有什么区别?
- 键存在且值为 null,表示"显式置空";键不存在,表示"本次未提及"。两者在数据层完全可区分,所以接口契约必须分别定义它们的含义。更新类接口尤其要写清:null 是清空还是删除字段,省略是不动还是取默认值。
- 几十兆的 JSON 文件该怎么读?
- 优先改变数据形态,而不是换解析器。把整份文档改成每行一个对象的 NDJSON,就能逐行解析、边读边处理,内存占用与文件大小脱钩;如果只能拿到整份文档、又只需要其中少数字段,就用事件式流解析。只有在几十 KB 到几 MB 的量级上,一次性解析才是合理选择。
- 文件内容看起来完全正常,为什么还是报解析错误?
- 最常见的原因是文件头部带了 UTF-8 BOM。BOM 肉眼不可见,但它是真实字符,会出现在第一个花括号或方括号之前,解析器读到它就直接报错,提示信息往往只说"意外的记号"。先用清洗工具去掉不可见字符,通常就能通过。
- JSON 能直接存日期和二进制吗?
- 都不能。JSON 的类型系统里没有日期,所以日期只能写成 ISO 8601 字符串,而且建议统一带时区、统一到毫秒;也没有二进制类型,只能用 Base64 编码成字符串,代价是体积膨胀约三分之一。真正大的文件不该走 JSON 通道,而该走独立的二进制传输。
九、三个反直觉的误区
误区一:同一份 JSON 在任何语言里解析结果都一样。文本层面的语法是统一的,数值层面却不是。JSON 规范把数字的范围与精度留给了实现,所以一个 19 位整数在 JavaScript 里会被舍入,在保留任意精度整数的实现里却能原样保留。这意味着一份"在服务端验证通过"的数据,到了浏览器里可能已经不是同一个值。跨端传递时把这类数值一律字符串化,"一样"才真正成立。
误区二:数据库里是数字,JSON 里当然也应该是数字。数据库的 bigint 有自己的取值范围,JSON 数字的取值范围却取决于解析它的语言。把 bigint 直接映射成 JSON 数字,等于把精度决策权交给了消费者,而消费者往往是最不受控的那一端。ID、卡号、单号这类标识符的本质是编码而不是数量,它们本来就不该参与算术运算。
误区三:压缩 JSON 能显著省流量。实测显示,在不压缩的通道上省 44.3%,在已经 gzip 的通道上只能再省 13.1%。现代浏览器、CDN 和网关默认都会对文本响应做压缩,去空白带来的收益因此远比想象中小。相比之下,减少字段、减少记录条数、把大响应改成分页,收益要直接得多。
十、四条可以直接执行的建议
- 把精度规则写进接口规范的第一页:ID 用字符串、金额用最小单位整数、时间用 ISO 8601 毫秒字符串。这三条一旦定下来,绝大多数"数据莫名变了"的问题会在源头消失。
- 契约里显式区分 null 与省略:不要在两个含义之间留解释空间,更新类接口必须写清 null 与缺键各自的语义。
- 拿到陌生 JSON 先校验和格式化,再比对:面对一段报错的数据,先用JSON 格式化工具确认语法并展开结构,再决定是改数据还是改解析代码;需要比较两次响应的差异时,先分别格式化再对比,可以避免键顺序变化造成的"满屏差异"。
- 大 payload 从数据形态入手:不要指望压缩解决内存问题,改成流式或分片的形态,收益远比调压缩级别大。
JSON 的边界总结起来只有一句:它擅长承载"结构",不擅长承载"类型"和"精度"。把它当作一层中立的文本传输协议,所有需要精度的东西(ID、金额、时间)都用字符串或整数显式表达,所有需要类型的东西(日期、二进制)都用约定补齐,剩下的问题就只剩数据量——而数据量是分片问题,不是格式问题。