Skip to content
返回博客
教程

如何在 JSON Diff 中忽略 Timestamps 和 IDs(不用 jq)

API 回归测试 80% 都是噪音 —— timestamps、request IDs、每次请求都变的 UUIDs。用 Extended JSON Pointer 模式,只暴露真正有意义的变化。

12 分钟阅读

如何在 JSON Diff 中忽略 Timestamps 和 IDs(不用 jq)

你给一个 REST 接口加了新字段。快照测试红了——不是因为字段变了,而是因为 createdAtrequestIdtraceId 在录制快照和重新运行之间发生了变化。你花十分钟确认这是噪音,手动静默了测试,继续工作。每个团队成员,每次构建,每周如此循环。

问题不在测试框架。问题在于原始 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 包含 requestIdtraceIdversionetagx-request-startserverTime 的快照文件。审查者对快照 diff 产生麻木,不读就直接批准。回归信号彻底失效。

解决方案不是停止快照测试,而是在 diff 运行之前去掉噪音。

什么算噪音:Timestamps、UUIDs、自动 ID、哈希

不是每个会变化的字段都是噪音。statusprocessing 变成 shipped 是信号。问题在于哪些字段在设计上就会轮换,因此结构上无法承载回归信息。

类别典型字段名变化原因
时间戳createdAtupdatedAtdeletedAtlastSeenexpiresAt服务器时钟、请求时刻
请求关联requestIdtraceIdspanIdcorrelationId每次请求生成
自动生成 IDiduuidnonceidempotencyKey每次插入生成 UUID v4 / ULID / KSUID
ETag / 缓存令牌etageTagcacheKeyversion(自增时)内容哈希或序列号
构建元数据buildHashdeployIdserverVersionhostname部署时设置
分页游标nextCursorprevCursorpageToken编码状态,不透明

表中字段是忽略列表的候选项。编码实际业务状态的字段——statusamountuserIditems——永远不应该出现在忽略列表中。

目标是一个存在于代码中、在 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-patchrfc6902 等库原生使用这种表示法。

通配符模式:/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.idrequest.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 面只包含 typedata/object/amountdata/object/currencydata/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 结尾(如 userIdorderId),需要细化 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" }
]

两个重要注意事项:

  1. 非往返警告。 过滤后的 patch 不能被应用以重建目标文档。如果你只将非噪音操作应用于源文档,得到的文档会缺少 timestamp 更新。存储完整 patch 用于文档重建;只将过滤后的 patch 用于 diff 和告警。

  2. 顺序敏感性。 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-patchcompare 函数返回 RFC 6902 操作。过滤器用与 Vitest 序列化器相同的 regex 方式对 path 字符串进行匹配。只有噪音变化时脚本退出 0,有任何有意义字段不同则退出 1。

对于通配符匹配(覆盖 /data/*/updatedAt 而非 /data/\d+\/updatedAt),可以用 glob-to-regex 转换器替换 regex 列表,或者使用 JSON Diff 工具接受的忽略列表格式,通过其导出功能集成到流水线中。

何时不该忽略:安全敏感字段、审计记录

忽略列表很强大。使用不当时也很危险。有些字段看起来像噪音,却承载着安全关键信息。

不要忽略:

  • 审计日志或财务记录中的 createdAt。如果一笔交易的 timestamp 是错误的,那是 bug,不是噪音。审计表的存在正是为了通过 timestamp 异常检测篡改行为。
  • 支付意图、发票或监管记录中的 id 字段。支付场景中旋转的 ID 可能表示重复收费或路由错误的事件。
  • 乐观并发系统中的 versionetag。如果两个进程都用相同的 version 写入,其中一个会失败。version 字段不是装饰性的。
  • 调试分布式系统故障时traceIdspanId。在快照测试中是噪音,在生产事故分析中是信号。
  • 你的安全团队标记为需要监控的任何字段——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 格式化工具清理压缩后的响应。