Skip to content
العودة إلى المدوّنة
دروس تعليمية

كيفية تجاهل الطوابع الزمنية والمعرّفات في مقارنة JSON (دون كتابة jq)

80% من فروقات اختبارات الانحدار في API مجرد ضوضاء — طوابع زمنية ومعرّفات طلبات و UUID تتغير مع كل طلب. استخدم أنماط JSON Pointer الموسّعة لإظهار التغييرات ذات المعنى فقط.

12 دقيقة للقراءة

كيفية تجاهل الطوابع الزمنية والمعرّفات في مقارنة JSON (دون كتابة jq)

تضيف حقلاً جديداً إلى نقطة نهاية REST، فتتحوّل اختبارات اللقطة إلى حمراء — ليس لأن الحقل تغيّر، بل لأن createdAt وrequestId وtraceId تغيّرت بين اللقطة المسجّلة وإعادة التشغيل. تقضي عشر دقائق في التأكد من أن الفرق مجرد ضوضاء، تُسكت الاختبار، وتمضي. يتكرر هذا مع كل عضو في الفريق، في كل بناء، كل أسبوع.

المشكلة ليست إطار الاختبار. المشكلة أن مقارنة JSON الخام تعامل كل حقل على قدم المساواة. معرّف UUID المتجدد مطابق هيكلياً لحقل يحمل دلالة حقيقية. دون وسيلة للقول “تجاهل هذا المسار”، تشمل سطح المقارنة كل طابع زمني يُضمّنه الخادم، وكل معرف تلقائي التزايد، وكل رمز ارتباط خاص بالطلب — لا شيء منها يعكس ما إذا كان عقد API قد تغيّر.

يغطي هذا الدليل الأدوات والأنماط التي تتيح لك قمع تلك الضوضاء من المصدر: صياغة مسار JSON Pointer، وأنماط أحرف البدل للمصفوفات، ومساعدات TypeScript للمطابقة القائمة على المفاتيح، وتكامل لقطات Vitest، وسكريبت CI صغير يخرج بكود غير صفري فقط حين يحتوي الفرق على شيء مهم فعلاً.

مشكلة “الفرق المزعج” — لماذا يفشل 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 ويشغّل مجموعة الاختبارات. يُظهر الفرق ستة أسطر متغيّرة — أربعة منها طوابع زمنية ومعرفات دورت من جانب الخادم. التغيير الوحيد ذو المعنى (الحقل الجديد) مدفون تحت الضوضاء. يتأكد زميلك أن لا شيء معطوباً، يُحدّث اللقطة، ويمضي الاستعراض دون أن يقرأ أحد الفرق الوظيفي فعلاً.

الآن اضرب هذا في شبكة خدمات مصغّرة بمئة نقطة نهاية. في كل بناء، يقارن CI ملفات اللقطة التي تحتوي على requestId وtraceId وversion وetag وx-request-start وserverTime. يتبلّد المراجعون على فروقات اللقطات ويوافقون عليها دون قراءة. تنهار إشارة الانحدار.

الحل ليس التوقف عن اختبار اللقطات. الحل هو تصفية الضوضاء قبل تشغيل المقارنة.

ما يُعدّ ضوضاء: الطوابع الزمنية و UUID والمعرفات التلقائية والتجزئات

ليس كل حقل متغيّر يُعدّ ضوضاء. حقل status الذي يتحوّل من processing إلى shipped هو إشارة. السؤال: أيّ الحقول عاجزة هيكلياً عن حمل معلومات الانحدار لأنها تتجدد بالتصميم؟

الفئةأمثلة على أسماء الحقولسبب التغيّر
الطوابع الزمنيةcreatedAt، updatedAt، deletedAt، lastSeen، expiresAtساعة الخادم، وقت الطلب
ارتباط الطلبrequestId، traceId، spanId، correlationIdيُولَّد لكل طلب
المعرفات التلقائيةid، uuid، nonce، idempotencyKeyUUID v4 / ULID / KSUID في كل إدراج
ETag ورموز التخزين المؤقتetag، eTag، cacheKey، version (عند التزايد التلقائي)تجزئة المحتوى أو التسلسل
بيانات البناءbuildHash، deployId، serverVersion، hostnameيُضبط وقت النشر
مؤشرات ترقيم الصفحاتnextCursor، prevCursor، pageTokenحالة مشفّرة غير شفافة

الحقول في هذا الجدول هي مرشّحات لقائمة التجاهل. الحقول التي ترمّز الحالة الفعلية — status وamount وuserId وitems — لا ينبغي أبداً أن تكون فيها.

الهدف قائمة تجاهل تعيش في الكود، تُراجَع في طلبات السحب، وتُدقَّق حين تتغيّر متطلبات الأمان. لا خط أنابيب jq كتبه أحدهم محلياً ونسي إيداعه.

مقدمة في صياغة JSON Pointer (RFC 6901)

JSON Pointer، المعرَّف في RFC 6901، هو مخطط العنونة الذي يستخدمه JSON Patch (RFC 6902) ومعظم أدوات المقارنة المنظّمة. يبدو كمسار نظام الملفات ويُعنوِن موقعاً محدداً في مستند JSON.

/               → المستند الجذر
/foo            → مفتاح الكائن "foo"
/foo/bar        → المفتاح المتداخل "bar" داخل "foo"
/items/0        → العنصر الأول من مصفوفة "items"
/items/0/name   → المفتاح "name" داخل العنصر الأول

تتولى تسلسلتا الهروب معالجة الأحرف الخاصة:

  • ~0 تمثّل حرف ~ الحرفي
  • ~1 تمثّل حرف / الحرفي

لذا يُعنوَن المفتاح a/b بـ/a~1b، ويُعنوَن المفتاح a~b بـ/a~0b.

يختلف JSON Pointer عن JSONPath (المستخدم في jq وOpenAPI) في نقطة جوهرية: إنه يُعنوِن موقعاً واحداً بالضبط — لا أحرف بدل، لا نزول تكراري، لا تعبيرات تصفية. RFC 6901 صارم وبسيط. المقايضة أن تجاهل حقل عبر جميع عناصر مصفوفة يستلزم إما أداة تُوسّع المواصفة بصياغة بدل، أو التكرار على مسارات المؤشر برمجياً.

حين ترى حقول path في عمليات JSON Patch (مثل [{"op":"replace","path":"/status","value":"shipped"}])، فتلك مؤشرات RFC 6901. أدوات كـfast-json-patch وrfc6902 تستخدم هذه التدوين نصياً.

أنماط أحرف البدل: /users/*/lastSeen للمصفوفات

لا يُعرِّف RFC 6901 أحرف البدل. لكن معظم أدوات المقارنة العملية توسّع المواصفة بحرف بدل لمقطع واحد: * يطابق أي مقطع مسار واحد دون تجاوز /.

قواعد نمط Extended JSON Pointer Pattern (كما نُفِّذ في أداة Go Tools JSON Diff):

  • * يطابق أي مقطع مسار واحد (فهرس مصفوفة أو مفتاح كائن)
  • * لا يتجاوز / — ليس نجمة اتساق تكرارية
  • لمطابقة نجمة حرفية في اسم مفتاح، اهرب منها بـ\*
  • ** (النزول التكراري بنجمتين) غير مدعوم — استخدم مسارات صريحة

أمثلة:

/users/*/lastSeen        → يطابق /users/0/lastSeen, /users/1/lastSeen, إلخ
/orders/*/updatedAt      → يطابق updatedAt في كل عنصر من مصفوفة orders
/*/requestId             → يطابق requestId في كل مفتاح كائن على المستوى الأعلى
/data/items/*/id         → يطابق id في كل عنصر من مصفوفة items المتداخلة

قيد المستوى الواحد مهم. /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

تغطي هذه الحالات الشائعة دون المساس بأي حقل دلالي.

عرض مباشر: تجاهل createdAt في حمولة Stripe Webhook

تُعدّ Stripe webhooks مثالاً ملموساً. يحتوي كل غلاف حدث على created (طابع زمني Unix) و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

بعد تطبيق هذه التجاهلات، يحتوي سطح المقارنة فقط على type وdata/object/amount وdata/object/currency وdata/object/status — الحقول التي تُشفِّر فعلاً ما إذا كان سلوك الدفع قد تغيّر.

الصق الحمولتَين قبل وبعد في أداة مقارنة JSON، أدخل مسارات التجاهل، ولن يبقى في مخرجات JSON Patch إلا العمليات ذات المعنى. لا خط أنابيب jq. لا سكريبت معالجة مسبقة.

مطابقة الحقول القائمة على Regex للمفاتيح غير المعروفة

تعمل مسارات JSON Pointer حين تعرف البنية مسبقاً. تفشل في سيناريوهَين: مفاتيح الكائن الديناميكية (خرائط بمفاتيح UUID، نقاط بيانات لكل مستخدم) وأنماط “أي حقل اسمه ينتهي بـId”.

لهذه الحالات، تحتاج مطابقة مفاتيح قائمة على Regex. إليك مساعد TypeScript يمشي في شجرة JSON ويجمع كل مسارات المؤشر التي يطابق اسم مفتاحها نمطاً معيناً:

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;
}

// Usage: collect all paths whose key name ends in "Id" or "At"
const noisePattern = /(?:Id|At|Stamp|Hash|Token|Cursor)$/;
const ignorePaths = findPathsByKeyPattern(responseJson, noisePattern);
// → ["/requestId", "/data/0/createdAt", "/data/0/updatedAt", ...]

يمنحك هذا قائمة تجاهل ديناميكية مستمدة من شكل الاستجابة الفعلية بدلاً من مجموعة سلاسل يصونها يدوياً. التحذير: إن انتهى حقل مشروع بـId (كـuserId أو orderId)، فعليك إما تدقيق النمط أو استبعاد تلك المسارات من النتيجة. أنماط الأسماء لا تُعوِّض قوائم المسارات الصريحة — بل تكمّلها للـAPIات الكبيرة التي يستحيل فيها صيانة كل مسار يدوياً.

مخرجات JSON Patch مع تصفية المسارات المتجاهلة

فرق JSON المعبَّر عنه كـJSON Patch (RFC 6902) هو قائمة عمليات:

[
  { "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. تحذير عدم التناسق العكسي. لا يمكن تطبيق تصحيح مصفًّى لإعادة بناء المستند الهدف. إن طبّقت العمليات غير الضوضائية فقط على المصدر، تحصل على مستند تنقصه تحديثات الطوابع الزمنية. احتفظ بالتصحيح الكامل لإعادة بناء المستند؛ استخدم التصحيح المصفّى للمقارنة والتنبيه فقط.

  2. الحساسية للترتيب. عمليات JSON Patch مرتّبة. تصفية عمليات من منتصف تصحيح يحتوي على عمليات نقل أو نسخ تشير إلى مواضع سابقة قد تُنتج تصحيحاً غير صالح. إن كنت بحاجة لتطبيق التصحيح المصفّى، طبّق التصحيح الكامل أولاً، ثم أعد المقارنة مع حالتك المتوقعة.

تُخرج أداة مقارنة JSON التصحيح الكامل والعرض المصفّى معاً، حتى تتمكن من فحص الفرق الخام قبل تقرير العمليات التي ستقمعها.

وصفات اختبار اللقطات (Jest / Vitest)

نظام لقطات Vitest يُسلسل القيم إلى سلاسل ويقارنها عند إعادة التشغيل. المُسلسِل الافتراضي يلتقط كل حقل بما فيها الحقول الضوضائية. الحل مُسلسِل مخصص يجرّد المسارات المتجاهلة قبل التقاط اللقطة.

// 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 etc. are stripped
});

يحتوي ملف اللقطة على الحقول الثابتة فقط. إعادة تشغيل المجموعة بعد تغيير طابع زمني تُنتج صفراً من الفروقات. حين يُضاف حقل shippingCarrier، يُظهر فرق اللقطة هذا التغيير الواحد بالضبط. يمكنك أيضاً استخدام أداة مقارنة JSON للتحقق يدوياً مما تغيّر بين استجابتَين مسجّلتَين قبل تحديث اللقطات.

تكامل CI: فشل البناء فقط عند الفروقات ذات المعنى

الهدف النهائي: خطوة CI تنزّل استجابة مرجعية، تضرب نقطة النهاية المباشرة، تقارن الاثنتين، تصفّي الضوضاء، وتخرج بكود غير صفري فقط إن بقيت عمليات ذات معنى.

// scripts/api-regression-check.ts
import { applyPatch, 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("Meaningful diff detected:");
    console.error(JSON.stringify(meaningfulOps, null, 2));
    process.exit(1);
  }

  console.log(`Clean diff (${ops.length} noise ops suppressed).`);
}

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

دالة compare من fast-json-patch تُعيد عمليات RFC 6902. يعمل المرشّح على سلاسل path باستخدام نفس نهج Regex كمُسلسِل Vitest. يخرج السكريبت بـ0 إن تغيّرت الضوضاء فقط، وبـ1 إن اختلف أي حقل ذو معنى.

للمطابقة بأحرف البدل (تغطية /data/*/updatedAt بدلاً من /data/\d+\/updatedAt)، استبدل قائمة Regex بمحوّل glob-to-regex أو استخدم تنسيق قائمة التجاهل المقبول في أداة مقارنة JSON وادمجها في خط أنابيبك عبر تصديرها.

متى لا تتجاهل: الحقول الحساسة أمنياً ومسارات التدقيق

قائمة التجاهل أداة قوية. وهي خطرة حين تُطبَّق دون انضباط. بعض الحقول تبدو كضوضاء لكنها تحمل معلومات حرجة أمنياً.

لا تتجاهل:

  • createdAt في سجلات التدقيق أو السجلات المالية. إن كان طابع زمني للمعاملة خاطئاً، فهذا خطأ — لا ضوضاء. جداول التدقيق موجودة تحديداً للكشف عن العبث عبر شذوذات الطوابع الزمنية.
  • حقول id في نوايا الدفع أو الفواتير أو السجلات التنظيمية. معرّف متجدد في سياق دفع قد يشير إلى رسوم مكررة أو أحداث موجَّهة خاطئاً.
  • version أو etag في أنظمة التزامن المتفائل. إن كتبت عمليتان بنفس الإصدار، ستفشل إحداهما. حقل الإصدار ليس زخرفاً.
  • traceId أو spanId عند تشخيص أعطال الأنظمة الموزعة. هذه ضوضاء في اختبارات اللقطات لكنها إشارة في تحليل حوادث الإنتاج.
  • أي حقل أشارت إليه فريق الأمان للمراقبة — قواعد SIEM ومتطلبات الامتثال وسياسات الاحتفاظ بالبيانات قد تعتمد على رؤية هذه الحقول في المقارنة.

النموذج الصحيح هو معاملة قائمة التجاهل كقائمة مسموح بها مراجَعة برمجياً: ثابت مسمّى أو ملف إعداد، مُودَع في المستودع، مع تعليق يوضح سبب وجود كل مسار فيها. ينبغي أن تتطلب التغييرات على قائمة التجاهل نفس الاستعراض المطلوب لتغييرات إعداد الإنتاج.

// Explicit, auditable, requires PR review to change
export const SNAPSHOT_IGNORE_PATHS = [
  "/requestId",   // per-request UUID, no semantic content
  "/traceId",     // OpenTelemetry trace ID, rotates per request
  "/createdAt",   // server clock, stable in production fixtures
  // "/amount"    // NEVER ignore — financial field
] as const;

إن لم تكن متأكداً من وضع حقل في قائمة التجاهل، أخطأ نحو إبقائه في الفرق. إيجابي كاذب (ضوضاء في الفرق) يكلّف ثوانٍ من الاستعراض. سلبي كاذب (انحدار فائت لأن المسار كان متجاهلاً) يكلّف مستخدميك.

الخلاصة

فروقات JSON الخام مزعجة لأن استجابات الخادم تُضمِّن حقولاً تتجدد بالتصميم. صياغة JSON Pointer (RFC 6901) تمنحك مخطط عنونة دقيقاً. أنماط أحرف البدل (كـ/data/*/createdAt) تُمدّد ذلك للمصفوفات دون كتابة كود اجتياز مخصص. المطابقة القائمة على Regex تتعامل مع البنى الديناميكية حيث لا تُعرَف المسارات مسبقاً. مخرجات JSON Patch (RFC 6902) بعد تصفية المسارات المتجاهلة تُنتج إشارة نظيفة لـCI. مُسلسِل مخصص في Vitest يُطبّق نفس المنطق على اختبارات اللقطات. وقائمة تجاهل منضبطة مراجَعة برمجياً تضمن ألا يبتلع قمع الضوضاء عن طريق الخطأ انحداراً حقيقياً.

جرّبها عملياً في أداة مقارنة JSON — الصق استجابتَي JSON قبل وبعد، أدخل مسارات التجاهل، وستجد العمليات ذات المعنى فقط. استخدم منسّق JSON لتنظيف الاستجابات المضغوطة قبل المقارنة.

مقالات ذات صلة

عرض جميع المقالات