JSON 规范化:为什么同样的对象算出了两个哈希
键的顺序、数字写法、转义方式都会改变字节流。要让哈希稳定,必须在序列化这一层定死规则,而不是指望 JSON.stringify 每次都给你同一串。
同一份数据算出两个哈希,八成不是哈希函数的问题,而是序列化没定死规则。JSON.stringify 的输出依赖于对象的键插入顺序,而这在多处拼装对象时根本不保证。
JSON.stringify({ a: 1, b: 2 }) // '{"a":1,"b":2}'
JSON.stringify({ b: 2, a: 1 }) // '{"b":2,"a":1}' ← 同样的数据
规范化要定死三件事
键的排序。 按 Unicode 码点升序排列,递归应用。
数字的写法。 1、1.0、1e0 在 JSON 里是同一个值,但字节不同。规范做法是统一成最短十进制表示,且禁止指数形式。
字符串的转义。 / 与 / 等价,é 与 é 等价。必须规定哪些字符走转义、哪些原样输出。
一个够用的实现
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error('non-finite');
return JSON.stringify(value); // 先接受 JS 的最短表示
}
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalize).join(',') + ']';
}
const keys = Object.keys(value as object).sort();
const parts = keys.map(
(k) => JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k]),
);
return '{' + parts.join(',') + '}';
}
它解决键顺序,但没有解决数字:1.0 在 JS 里就是 1,问题被语言本身挡掉了;但如果是先解析别人的 JSON 字符串再重新序列化,1e2 会变成 100,这反而是我们想要的归一。
什么时候真的需要
| 场景 | 需要吗 | 原因 |
|---|---|---|
| 内容寻址存储 | 需要 | 同一内容必须同一地址 |
| 请求签名 | 需要 | 客户端与服务端要算出一致的结果 |
| 缓存键 | 需要 | 否则等价请求会各占一份缓存 |
| 幂等键 | 需要 | 否则重放会被当成新请求 |
| 普通日志 | 不需要 | 没人在意键顺序 |
签名场景的额外要求
做请求签名时,规范化后的字节流必须双方约定同一份规范,且规范本身要写进 API 文档。JCS(RFC 8785)就是为此定义的:它把数字限定成 ECMAScript 的 Number::toString 输出,把排序限定成 UTF-16 码元。
别自己发明一套 —— 签名双方各写一个规范化函数,就是等着某天在某个边界值上对不上。
哈希不稳定时先查序列化,再查哈希。绝大多数「哈希不一致」最后都指向键顺序。

评论
…