JSON Toolkit

إصدارات عقود JSON: التوافق، الإهلاك، وترحيل البيانات

الخلاصة: إضافة حقل ليست آمنة تلقائياً. يغطي حدود التوافق، وحقول الإصدار، ونوافذ الإهلاك، وترحيل dual-write، واختبارات العقود، ومقاييس الكناري.

متقدمقراءة نحو 10 دقيقةنُشر في 2026-10-31حُدِّث في 2026-10-31
غلاف إصدارات عقود JSON والتوافق وترحيل البيانات

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

الجزء الصعب في الإصدارات ليس تغيير الكود؛ بل أنك لا تستطيع معرفة من ما زال يقرأ الحقل القديم. التوافق ليس وعداً أحادياً أبداً — إنه مواءمة مستمرة بين الناشر ومستهلكين مجهولين.

1. حدود التوافق: الإضافة ليست آمنة تلقائياً

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

  • يتحقق المستهلك بـschema يضبط additionalProperties: false؛
  • يجتاز المستهلك الكائن بترتيب ثابت، أو يقرأ القيم بالموضع لا بالمفتاح؛
  • يعيد الحقل الجديد استخدام اسم قديم لكن يغيّر معناه، مثلاً توسيع status من "حالة الطلب" إلى "حالة التنفيذ".

الإضافة الآمنة تحتاج ثلاثة شروط مسبقة: أن يكون الحقل اختيارياً، وأن يكون معناه مستقلاً، وأن يتجاهل المستهلكون المفاتيح غير المعروفة. أكّد الثلاثة قبل أن تناقش رقم إصدار أصلاً.

{
  "schemaVersion": 2,
  "orderId": "ord-8842",
  "status": "paid",
  "refundDeadline": "2026-11-30T00:00:00+08:00",
  "channel": "wechat"
}

هنا refundDeadline وchannel إضافتان اختياريتان في v2، بينما يحتفظ orderId وstatus بدلالاتهما الأصلية. وschemaVersion محفوظ مع الوثيقة لا موجود فقط في ترويسة HTTP.

2. تغييرات كاسرة لا يمكنك نشرها

تعامل مع التغييرات التالية كخطوط حمراء تتطلب إصداراً جديداً أو انتقال dual-write. تحديث الوثائق لن ينقذك:

التغيير لماذا يكسر التوافق البديل المقبول
إزالة حقل يقرأ المستهلكون undefined فوراً أهلك، راقب، ثم أزل بعد النافذة
تغيير نوع حقل "1" يصبح 1؛ العملاء المتزامنون بقوة يفشلون صراحةً والمرنون غالباً يفشلون في التحقق أضف اسم حقل جديداً واكتب dual-write أثناء الانتقال
إضافة حقل مطلوب المستدعون القدامى لا يرسلونه، فيفشل التحقق دائماً وفّر قيمة افتراضية، أو اجعله مطلوباً في الإصدار الجديد فقط
تغيير مجموعة قيم enum عبارات switch في المستهلك تفتقد فرعاً بديلاً علّم العملاء قبول القيم غير المعروفة أولاً، ثم وسّع enum
تغيير معنى حقل تبقى الأنواع صالحة بينما يتغير السلوك — أصعب حالة في التصحيح عامله كحقل جديد وجمّد الدلالات القديمة
تغيير عمق التداخل يجب أن يتغير كل مسار وصول أبقِ الشكل القديم وأضف حقولاً مسطّحة أو معامل expand

تغيير المعنى هو أخطر صف: يبقى تحقق schema أخضر، والمراقبة لا تبلّغ عن أخطاء، ولا تنحرف سوى مقاييس الأعمال. أي تغيير في المعنى يجب أن يُشحن كحقل جديد.

3. أين يعيش الإصدار: الوثيقة، URL، نوع الوسيط

المواضع الثلاثة الشائعة تخدم أغراضاً مختلفة:

الموقع مثال جيد لـ القيود
حقل في الوثيقة "schemaVersion": 2 قواعد البيانات، قوائم الرسائل، نسخ احتياطية دون اتصال طبقة النقل لا تستطيع التوجيه به
مسار URL /v2/orders/8842 APIs عامة يربط عناوين الموارد بالإصدارات ويجعل الترحيل مكلفاً
نوع الوسيط application/vnd.acme.order.v2+json الخدمات الداخلية التي تحتاج تفاوض محتوى يجب أن يضبط المستدعون Accept صحيحاً

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

GET /orders/ord-8842 HTTP/1.1
Host: api.bbdrug.com
Accept: application/vnd.acme.order.v2+json

إذا كنت تُصدر URL بالإصدار، فاحتفظ على الأقل بالمسار القديم يعيد الشكل القديم طوال نافذة الإهلاك بدلاً من توجيهه بصمت إلى التنفيذ الجديد. لقرارات عقد API الأوسع، انظر النقاط الأساسية لتصميم عقود بيانات JSON API.

4. فتح نافذة إهلاك

الإهلاك ليس إعلاناً واحداً؛ إنه نافذة لها بداية وفترة مراقبة ونهاية. إيقاع عملي:

  1. تجميد: توقف عن كتابة الحقل القديم، لكن استمر في قراءته.
  2. إعلان: حدد تاريخ الإزالة في الوثائق وسجل التغييرات وترويسة استجابة في آن واحد.
  3. مراقبة: احصِ قراءات الحقل القديم لكل مستدعٍ حتى تبقى صفراً لفترتين متتاليتين.
  4. إزالة: بعد إغلاق النافذة فقط، وعندما تكون القراءات صفراً واختبارات الانحدار ناجحة، احذف الحقل وكود توافقه.

تمنح ترويسة الاستجابة Sunset (RFC 8594) للعملاء تاريخ إزالة قابلاً للقراءة الآلية. وDeprecation ما زالت مسودة IETF: قابلة للاستخدام، لكن لا تفترض أن كل عميل يفهمها. اقرنها بترويسة Link تشير إلى دليل الترحيل:

Deprecation: true
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://bbdrug.com/docs/deprecations/order-status>; rel="deprecation"; type="text/html"

طول النافذة اتفاقية فريق. الحد الأدنى الشائع دورة إصدار واحدة أو 90 يوماً، أيهما أطول. APIs العامة يجب أن تحصل على أكثر، والخدمات الداخلية على أقل، لكن أبداً أقل من الوقت الذي يحتاجه كل مستدعٍ للترقية.

5. العثور على المستهلكين قبل الإهلاك

قبل الإهلاك، يجب أن تستطيع الإجابة عن "من ما زال يقرأ هذا؟" وإلا فالنافذة خداع للنفس. رتّب الأدلة حسب الموثوقية:

  • سجلات البوابة: جمّع وصول الحقل القديم حسب appId وUser-Agent وإصدار العميل.
  • التتبعات الموزعة: ميّز الحقل القديم في طبقة التسلسل حتى يعرض التتبع اسم الخدمة المستدعية مباشرةً.
  • سجل مستهلكي قائمة الانتظار: قائمة مجموعات المستهلكين أكثر موثوقية من البحث في الكود.
  • البحث في المستودع: rg يغطي كودك فقط؛ والمستدعون الخارجيون ما زالوا يحتاجون إلى السجلات.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

نتيجة بحث تثبت "شخص في هذا المستودع يقرؤه"، لا "لا أحد غيرنا". صدّر سجلات البوابة يوماً بيوم وأكّد أن قراءات الحقل القديم بقيت صفراً قبل إزالة أي شيء.

6. ترحيل البيانات: expand، backfill، contract

يصف Martin Fowler التغيير المتوازي بثلاث خطوات: expand (حيث يتعايش الحقلان القديم والجديد ويمكن قراءة الإصدارين)، وbackfill (نقل السجلات التاريخية إلى الشكل الجديد)، وcontract (إزالة الحقل القديم وفروع التوافق). لا تعكس الترتيب، وإلا فليس لديك مسار تراجع.

// هيكل الترحيل: مؤشر بالمفتاح الرئيسي، وكتابات شرطية لضمان التكرارية
async function migrateBatch(collection, cursor, batchSize = 500) {
  const records = await collection
    .find({ _id: { $gt: cursor } })
    .limit(batchSize)
    .toArray();

  if (records.length === 0) {
    return { nextCursor: cursor, done: true };
  }

  const operations = records
    .filter((record) => record.schemaVersion < 2)
    .map((record) => ({
      updateOne: {
        filter: { _id: record._id, schemaVersion: record.schemaVersion },
        update: {
          $set: {
            schemaVersion: 2,
            status: record.legacyStatus ?? record.status,
            refundDeadline: record.refundDeadline ?? null,
          },
        },
      },
    }));

  if (operations.length > 0) {
    await collection.bulkWrite(operations);
  }

  return { nextCursor: records.at(-1)._id, done: false };
}

تفاصيل يسهل تفويتها:

  • التكرارية: أدرج رقم الإصدار القديم في filter، حتى لا تعيد إعادة التشغيل كتابة سجل رُحّل بالفعل.
  • التجميع ونقاط التحقق: تقدّم بمؤشر _id، وخزّن نقطة تحقق، واستأنف منها بعد فشل.
  • البروفة: شغّل السكربت على لقطة إنتاج وقارن عدد السجلات والمجاميع قبل وبعد.
  • التراجع: أبقِ الحقل القديم حتى تجتاز القيم الجديدة التحقق؛ بدّل مسارات القراءة براية ولا تتراجع أبداً بتحرير schemaVersion وحده.
  • التحقق: شغّل قيود الأعمال بعد الترحيل (مبالغ غير سالبة، انتقالات حالة قانونية)، لا مجرد فحص تحليل JSON.

بيانات الإعدادات تتبع العملية نفسها؛ انظر إدارة JSON للإعدادات وfixtures والبيانات المحلية لمعرفة كيفية تنظيم تلك الملفات.

7. اختبارات العقود: ضع التوافق في CI

لا يمكن أن يعتمد التوافق على ذاكرة مراجعة الكود؛ إنه يحتاج اختبارات. طبقتان تكفيان:

  1. اختبارات الشكل: تحقق من الاستجابات بـJSON Schema، لكن لا تضبط additionalProperties: false على Schemas يستخدمها المستهلكون.
  2. اختبارات مجموعة المستهلك الفرعية: استخرج الحقول التي يعتمد عليها المستهلك القديم فعلاً إلى sub-schema وأكّد أن الاستجابات الجديدة ما زالت تلبيها.
// اختبار عقد: يجب أن تلبي الاستجابات الجديدة مجموعة المستهلك القديم
import Ajv from 'ajv';
import assert from 'node:assert/strict';
import { test } from 'node:test';

const ajv = new Ajv({ strict: false });
const validateLegacySubset = ajv.compile(legacyConsumerSubsetSchema);

test('a v2 response still satisfies the v1 consumer subset', () => {
  const response = buildOrderResponse({ schemaVersion: 2 });
  const { schemaVersion, ...legacyFields } = response;
  const isValid = validateLegacySubset(legacyFields);

  assert.equal(isValid, true, JSON.stringify(validateLegacySubset.errors));
});

يعلن sub-schema فقط الحقول والأنواع التي يقرؤها المستهلك القديم ويسمح بمرور كل حقل جديد. ذلك يلتقط الحقول المحذوفة وتغييرات الأنواع دون إنذارات كاذبة عن الإضافات. احتفظ ببيانات الحالات في مجلد fixtures، مرتبة وقابلة للـdiff؛ لتعارضات الدمج في تلك الملفات، انظر تعارضات Git في ملفات JSON: union يكتب JSON معطوباً، فادمج بالمفاتيح بدلاً من ذلك.

8. الطرح الكناري: كبّر بالبُعد، وأوقف بالمقاييس

نادراً ما تظهر مشكلات التوافق في اختبارات الوحدة؛ إنها تظهر في الكناري. وسّع التعرّض من الضيق إلى الواسع:

البُعد الممارسة ما يتحقق منه
قائمة المستأجرين المسموحين فعّل مسار القراءة الجديد للعملاء الداخليين أو الودودين أولاً فروق شكل البيانات في الواقع
إصدار العميل فعّل فقط للعملاء المرقّين أصلاً إلى الشكل الجديد عملاء قدامى وجدد يعملون جنباً إلى جنب
نسبة الحركة 5% → 25% → 100%، مع مراقبة دورة أعمال كاملة في كل خطوة الاستدعاءات ذات الذيل الطويل وسلوك إصابة ذاكرة التخزين

راقب ثلاثة مقاييس على الأقل: معدل أخطاء API، ومعدل فشل تحقق العقد، ونسبة القراءة بين الحقلين القديم والجديد. عرّف شروط إيقاف تلقائية، مثل 5xx فوق 1% أو ارتداد مفاجئ في قراءات الحقل القديم، وتراجع بالراية. أبقِ الكتابات المزدوجة أثناء الكناري حتى يتطلب التراجع تبديل مسار القراءة فقط، لا إصلاح البيانات.

9. قائمة فحص التوافق قبل النشر

  • [ ] الحقول الجديدة اختيارية ومستقلة دلالياً ولا تعيد استخدام أسماء قديمة.
  • [ ] يتعامل المستهلكون مع الحقول والقيم غير المعروفة ببديل.
  • [ ] حقل الإصدار محفوظ مع الوثيقة وقابل للتفاوض على الشبكة.
  • [ ] نافذة الإهلاك لها إعلان وترويسة Sunset ومراقبة.
  • [ ] يمكن تتبع قراءات الحقل القديم إلى مستدعين محددين عبر السجلات أو التتبعات.
  • [ ] سكربت الترحيل تكراري ومجمّع وقابل للاستئناف وله مسار تراجع.
  • [ ] اختبارات العقود تغطي مجموعة المستهلك القديم وتعمل في CI.
  • [ ] الكناري له أبعاد تعرّض صريحة ومقاييس إيقاف.

FAQ

هل إضافة حقل اختياري متوافقة خلفياً دائماً؟

لا. إنها متوافقة فقط عندما يتجاهل المستهلكون الحقول غير المعروفة ويتجنبوا تحقق schema الصارم. المستهلك الذي يستخدم additionalProperties: false أو يقرأ بالموضع ما زال سيفشل. تحقق من تسامحهم باختبار عقد قبل النشر.

هل يجب أن يعيش schemaVersion في URL أم في جسم الاستجابة؟

يمكن للشبكة أن تتفاوض على الإصدارات عبر أنواع الوسائط أو مسارات URL، لكن السجلات المخزّنة يجب أن تحمل schemaVersion داخل الوثيقة. قوائم الانتظار والنسخ الاحتياطية دون اتصال والتدقيقات لا تمر عبر URL أبداً، لذا فقط إصدار داخل الوثيقة يدعم الإعادة والتعبئة الخلفية.

هل يمكن حذف حقل مهجور فوراً؟

لا. توقف عن كتابته، وأعلن تاريخ الإزالة، واحصِ القراءات لكل مستدعٍ. احذفه فقط بعد إغلاق النافذة وبقاء القراءات صفراً. قبل الحذف، شغّل انحداراً كاملاً لتأكيد عدم وجود مهمة cron أو تقرير مخفي ما زال يقرؤه.

ماذا لو فشل سكربت ترحيل في منتصف الطريق؟

صمّمه كمهمة تكرارية: تحديثات شرطية، ومؤشر بالمفتاح الرئيسي، ونقاط تحقق. استأنف من نقطة التحقق بعد الفشل، ويجب ألا تكتب التشغيلات المتكررة مرتين. للتراجع، أبقِ الحقل القديم ولقطة؛ ولا تُرجع schemaVersion وحده فقط.

كيف يجب أن أرتّب ترحيل البيانات؟

اتبع التغيير المتوازي: وسّع أولاً حتى يتعايش الحقلان القديم والجديد ويمكن قراءة الإصدارين، ثم عبّئ السجلات التاريخية في الشكل الجديد، ثم قلّص بإزالة الحقل القديم وفروع التوافق. لا تعكس الترتيب وإلا تفقد مسار التراجع، واجعل سكربت الترحيل تكراريًا ومجمّعاً وقابلاً للاستئناف.