如何在 JSON Diff 中忽略 Timestamps 和 IDs(不用 jq)
你给一个 REST 接口加了新字段。快照测试红了——不是因为字段变了,而是因为 createdAt、requestId、traceId 在录制快照和重新运行之间发生了变化。你花十分钟确认这是噪音,手动静默了测试,继续工作。每个团队成员,每次构建,每周如此循环。
问题不在测试框架。问题在于原始 JSON diff 把所有字段一视同仁。一个每次都不同的 UUID 在结构上与承载语义信息的字段没有区别。没有办法说「忽略这个路径」,diff 面就会包含服务端内嵌的每个 timestamp、每个自动生成的 ID、每个每次请求都变的关联 token——这些都不能反映 API 契约是否发生了变化。
本文介绍能从源头抑制这些噪音的工具和模式:JSON Pointer 路径语法、数组通配符模式、基于键名的 TypeScript 匹配辅助函数、Vitest 快照集成,以及一个只在 diff 包含真正有意义的内容时才退出非零状态的小型 CI 脚本。
「噪音 diff」问题——为何 CI 因无关变化失败
考虑一个典型的 API 回归工作流。你录制了 GET /orders/42 的响应:
{
"id": "ord_9f8e7d6c",
"status": "shipped",
"createdAt": "2026-04-01T10:00:00Z",
"updatedAt": "2026-04-01T12:34:56Z",
"requestId": "req_abc123",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
你将这个响应存为快照。两周后,同事加了一个 shippingCarrier 字段并运行测试套件。diff 显示六行变化——四行是服务端旋转的 timestamps 和 IDs。那一个有意义的变化(新字段)被噪音淹没了。同事确认没有破坏任何东西,更新快照,review 在没人真正阅读功能 diff 的情况下通过了。
把这个场景乘以一个有一百个接口的微服务网格。每次构建,CI 都在 diff 包含 requestId、traceId、version、etag、x-request-start、serverTime 的快照文件。审查者对快照 diff 产生麻木,不读就直接批准。回归信号彻底失效。
解决方案不是停止快照测试,而是在 diff 运行之前去掉噪音。
什么算噪音:Timestamps、UUIDs、自动 ID、哈希
不是每个会变化的字段都是噪音。status 从 processing 变成 shipped 是信号。问题在于哪些字段在设计上就会轮换,因此结构上无法承载回归信息。
| 类别 | 典型字段名 | 变化原因 |
|---|---|---|
| 时间戳 | createdAt、updatedAt、deletedAt、lastSeen、expiresAt | 服务器时钟、请求时刻 |
| 请求关联 | requestId、traceId、spanId、correlationId | 每次请求生成 |
| 自动生成 ID | id、uuid、nonce、idempotencyKey | 每次插入生成 UUID v4 / ULID / KSUID |
| ETag / 缓存令牌 | etag、eTag、cacheKey、version(自增时) | 内容哈希或序列号 |
| 构建元数据 | buildHash、deployId、serverVersion、hostname | 部署时设置 |
| 分页游标 | nextCursor、prevCursor、pageToken | 编码状态,不透明 |
表中字段是忽略列表的候选项。编码实际业务状态的字段——status、amount、userId、items——永远不应该出现在忽略列表中。
目标是一个存在于代码中、在 PR 中被 review、在安全需求变化时被审计的忽略列表。而不是某人在本地写的一次性 jq 管道,然后忘记提交。
JSON Pointer(RFC 6901)语法入门
JSON Pointer 定义于 RFC 6901,是 JSON Patch(RFC 6902)和大多数结构化 diff 工具使用的寻址方案。它看起来像文件系统路径,指向 JSON 文档中的特定位置。
/ → 根文档
/foo → 对象键 "foo"
/foo/bar → "foo" 内的嵌套键 "bar"
/items/0 → 数组 "items" 的第一个元素
/items/0/name → 第一个元素内的键 "name"
两个转义序列处理特殊字符:
~0表示字面量~~1表示字面量/
因此键 a/b 的地址是 /a~1b,键 a~b 的地址是 /a~0b。
JSON Pointer 与 JSONPath(jq、OpenAPI 使用)的关键区别:它精确地指向一个位置——没有通配符,没有递归下降,没有过滤表达式。RFC 6901 严格而简单。代价是:要忽略数组中所有元素的某个字段,需要一个扩展了通配符语法的工具,或者以编程方式迭代 Pointer 路径。
当你在 JSON Patch 操作中看到 path 字段([{"op":"replace","path":"/status","value":"shipped"}]),那些就是 RFC 6901 Pointer。fast-json-patch 和 rfc6902 等库原生使用这种表示法。
通配符模式:/users/*/lastSeen 用于数组
RFC 6901 没有定义通配符。但实践中有用的 diff 工具通常用单段通配符扩展了规范:* 匹配任意一个路径段,不跨越 /。
Extended JSON Pointer Pattern 的规则(Go Tools JSON Diff 工具的实现方式):
*匹配任意单个路径段(数组索引或对象键)*不跨越/——它不是递归 glob- 要匹配键名中的字面星号,转义为
\* - 不支持
**(双星递归下降)——请使用显式路径
示例:
/users/*/lastSeen → 匹配 /users/0/lastSeen、/users/1/lastSeen 等
/orders/*/updatedAt → 匹配 orders 数组每个元素的 updatedAt
/*/requestId → 匹配每个顶层对象键中的 requestId
/data/items/*/id → 匹配嵌套 items 数组每个元素的 id
单层限制很重要。/users/*/profile/*/tag 是合法的,精确匹配两层通配符下降。它不匹配 /users/0/tag(太浅)或 /users/0/profile/0/meta/tag(太深)。
典型 REST API 响应的实用忽略列表:
/requestId
/traceId
/createdAt
/updatedAt
/data/*/createdAt
/data/*/updatedAt
/data/*/id
/pagination/nextCursor
这覆盖了常见情况,不涉及任何语义字段。
实战演示:忽略 Stripe Webhook 载荷中的 createdAt
Stripe webhook 是一个具体例子。每个事件信封都包含 created(Unix 时间戳)、id(事件 ID)、request.id 和 request.idempotency_key——这些都是每次调用都会变化的,对回归测试毫无用处。
原始 webhook 载荷:
{
"id": "evt_1OqLmn2eZvKYlo2C9Pt1a4X7",
"object": "event",
"created": 1714819200,
"type": "payment_intent.succeeded",
"request": {
"id": "req_Bc7dEfGhIj",
"idempotency_key": null
},
"data": {
"object": {
"id": "pi_3OqLmn2eZvKYlo2C0Pt1a4X8",
"amount": 2000,
"currency": "usd",
"status": "succeeded"
}
}
}
针对这个结构的忽略列表:
/id
/created
/request/id
/request/idempotency_key
/data/object/id
应用这些忽略之后,diff 面只包含 type、data/object/amount、data/object/currency 和 data/object/status——真正编码支付行为是否变化的字段。
将前后两个载荷粘贴到 JSON Diff 工具,输入忽略路径,JSON Patch 输出中就只剩有意义的操作了。不需要 jq 管道,不需要预处理脚本。
基于 Regex 的字段匹配,用于未知键
当你事先知道结构时,JSON Pointer 路径有效。对于两种场景它会失效:动态对象键(以 UUID 为键的 map、按用户分组的数据 blob),以及「名称以 Id 结尾的所有字段」这类模式。
这类情况需要基于 regex 的键名匹配。以下是一个 TypeScript 辅助函数,遍历 JSON 树并收集所有键名匹配某个模式的 Pointer 路径:
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
function findPathsByKeyPattern(
value: JsonValue,
pattern: RegExp,
currentPath = ""
): string[] {
const paths: string[] = [];
if (Array.isArray(value)) {
value.forEach((item, i) => {
paths.push(...findPathsByKeyPattern(item, pattern, `${currentPath}/${i}`));
});
} else if (value !== null && typeof value === "object") {
for (const [key, child] of Object.entries(value)) {
const encodedKey = key.replace(/~/g, "~0").replace(/\//g, "~1");
const childPath = `${currentPath}/${encodedKey}`;
if (pattern.test(key)) {
paths.push(childPath);
}
paths.push(...findPathsByKeyPattern(child, pattern, childPath));
}
}
return paths;
}
// 用法:收集所有键名以 "Id"、"At" 等结尾的路径
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]
这给你一个从实际响应结构中动态派生的忽略列表,而不是手动维护的字符串集合。注意事项:如果合法字段的名称也以 Id 结尾(如 userId 或 orderId),需要细化 pattern 或从结果中减去这些路径。名称模式不能替代显式路径列表——它是大型 API 中手动维护每条路径不现实时的补充。
JSON Patch 输出:去掉被忽略路径之后
以 JSON Patch(RFC 6902)形式表达的 JSON diff 是一个操作列表:
[
{ "op": "replace", "path": "/updatedAt", "value": "2026-05-04T09:00:00Z" },
{ "op": "replace", "path": "/status", "value": "shipped" },
{ "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]
过滤忽略路径意味着移除 path 匹配忽略列表(含通配符)的操作。过滤后的输出:
[
{ "op": "replace", "path": "/status", "value": "shipped" },
{ "op": "add", "path": "/shippingCarrier", "value": "FedEx" }
]
两个重要注意事项:
-
非往返警告。 过滤后的 patch 不能被应用以重建目标文档。如果你只将非噪音操作应用于源文档,得到的文档会缺少 timestamp 更新。存储完整 patch 用于文档重建;只将过滤后的 patch 用于 diff 和告警。
-
顺序敏感性。 JSON Patch 操作是有序的。从中间过滤掉操作,如果后续有 move 或 copy 操作引用了被过滤操作之前的位置,可能会产生无效 patch。如果需要应用过滤后的 patch,先应用完整 patch,然后将结果与期望状态重新 diff。
JSON Diff 工具同时输出完整 patch 和过滤后的视图,让你在决定要抑制哪些操作之前先检查原始 diff。
快照测试方案(Jest / Vitest)
Vitest 的快照系统将值序列化为字符串,并在重新运行时 diff 它们。默认序列化器会捕获每个字段,包括噪音字段。解决方案是一个自定义序列化器,在快照之前剥离被忽略的路径。
// tests/setup/json-diff-serializer.ts
import { expect } from "vitest";
const IGNORE_PATHS = new Set([
"/requestId",
"/traceId",
"/createdAt",
"/updatedAt",
"/data/id",
]);
function stripPaths(obj: unknown, path = ""): unknown {
if (Array.isArray(obj)) {
return obj.map((item, i) => stripPaths(item, `${path}/${i}`));
}
if (obj !== null && typeof obj === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const childPath = `${path}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
if (!IGNORE_PATHS.has(childPath)) {
result[key] = stripPaths(value, childPath);
}
}
return result;
}
return obj;
}
expect.addSnapshotSerializer({
test: (val) => val !== null && typeof val === "object" && !Array.isArray(val),
print: (val, serialize) => serialize(stripPaths(val)),
});
在 vitest.config.ts 中注册:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./tests/setup/json-diff-serializer.ts"],
},
});
现在你的快照看起来像这样:
it("returns order with shipping carrier", async () => {
const result = await getOrder("ord_42");
expect(result).toMatchSnapshot(); // createdAt、requestId 等已被剥离
});
快照文件只包含稳定字段。timestamp 变化后重新运行测试套件,零 diff。当 shippingCarrier 字段被添加时,快照 diff 精确显示这一个变化。你也可以使用 JSON Diff 工具在更新快照之前手动验证两个录制响应之间发生了什么变化。
CI 集成:只在有意义的 Diff 时让构建失败
最终目标:一个 CI 步骤,下载参考响应,访问线上接口,diff 两者,剥离噪音,只在有意义的操作存在时才退出非零状态。
// scripts/api-regression-check.ts
import { compare } from "fast-json-patch";
import { readFileSync } from "fs";
const IGNORE_PATTERNS = [
/^\/requestId$/,
/^\/traceId$/,
/^\/createdAt$/,
/^\/updatedAt$/,
/^\/data\/\d+\/createdAt$/,
/^\/data\/\d+\/updatedAt$/,
/^\/data\/\d+\/id$/,
];
function isMeaningfulOp(op: { path: string }): boolean {
return !IGNORE_PATTERNS.some((re) => re.test(op.path));
}
async function main() {
const reference = JSON.parse(readFileSync("reference.json", "utf-8"));
const response = await fetch(process.env.API_URL + "/orders").then((r) => r.json());
const ops = compare(reference, response);
const meaningfulOps = ops.filter(isMeaningfulOp);
if (meaningfulOps.length > 0) {
console.error("发现有意义的 diff:");
console.error(JSON.stringify(meaningfulOps, null, 2));
process.exit(1);
}
console.log(`Diff 干净(已抑制 ${ops.length} 个噪音操作)。`);
}
main().catch((e) => { console.error(e); process.exit(1); });
GitHub Actions 步骤:
- name: API regression check
env:
API_URL: ${{ secrets.STAGING_API_URL }}
run: npx tsx scripts/api-regression-check.ts
fast-json-patch 的 compare 函数返回 RFC 6902 操作。过滤器用与 Vitest 序列化器相同的 regex 方式对 path 字符串进行匹配。只有噪音变化时脚本退出 0,有任何有意义字段不同则退出 1。
对于通配符匹配(覆盖 /data/*/updatedAt 而非 /data/\d+\/updatedAt),可以用 glob-to-regex 转换器替换 regex 列表,或者使用 JSON Diff 工具接受的忽略列表格式,通过其导出功能集成到流水线中。
何时不该忽略:安全敏感字段、审计记录
忽略列表很强大。使用不当时也很危险。有些字段看起来像噪音,却承载着安全关键信息。
不要忽略:
- 审计日志或财务记录中的
createdAt。如果一笔交易的 timestamp 是错误的,那是 bug,不是噪音。审计表的存在正是为了通过 timestamp 异常检测篡改行为。 - 支付意图、发票或监管记录中的
id字段。支付场景中旋转的 ID 可能表示重复收费或路由错误的事件。 - 乐观并发系统中的
version或etag。如果两个进程都用相同的 version 写入,其中一个会失败。version 字段不是装饰性的。 - 调试分布式系统故障时的
traceId或spanId。在快照测试中是噪音,在生产事故分析中是信号。 - 你的安全团队标记为需要监控的任何字段——SIEM 规则、合规要求和数据保留策略可能依赖这些字段在 diff 中可见。
正确的模式是把忽略列表当作经过代码 review 的白名单:一个有名称的常量或配置文件,提交到代码仓库,带注释说明每条路径为何在列表中。对忽略列表的修改应该与生产配置修改受到同等力度的 review。
// 明确、可审计,修改需要 PR review
export const SNAPSHOT_IGNORE_PATHS = [
"/requestId", // 每次请求的 UUID,无语义内容
"/traceId", // OpenTelemetry trace ID,每次请求轮换
"/createdAt", // 服务器时钟,生产夹具中稳定
// "/amount" // 永远不要忽略——财务字段
] as const;
如果不确定某个字段是否应该在忽略列表中,保守处理,保留在 diff 中。误报(diff 中有噪音)代价是几秒钟的 review。漏报(因路径被忽略而错过回归)代价是让用户承担。
小结
原始 JSON diff 有噪音,因为服务端响应内嵌了设计上就会轮换的字段。JSON Pointer(RFC 6901)语法给你一个精确的寻址方案。通配符模式(/data/*/createdAt)将其扩展到数组,无需编写自定义遍历代码。基于 regex 的键名匹配处理路径无法提前知道的动态结构。去掉被忽略路径的 JSON Patch(RFC 6902)输出为 CI 提供干净的信号。Vitest 自定义序列化器将同样的逻辑应用于快照测试。经过纪律约束、代码 review 的忽略列表确保噪音抑制不会意外吞掉真实的回归。
在 JSON Diff 工具中动手试试——粘贴两个 JSON 响应,输入忽略路径,只看到有意义的操作。diff 之前用 JSON 格式化工具清理压缩后的响应。