JSON Toolkit

Versioning Kontrak JSON: Kompatibilitas, Deprecation, dan Migrasi Data

Intinya: Menambah field tidak otomatis aman. Membahas batas kompatibilitas, field versi, jendela deprecation, migrasi dual-write, contract test, dan metrik canary.

Lanjutan±10 menit bacaDiterbitkan 2026-10-31Diperbarui 2026-10-31
Sampul untuk versioning kontrak JSON, kompatibilitas, dan migrasi data

Intinya: jalankan evolusi versi sebagai expand, backfill, lalu contract, dan simpan versi di dalam dokumen; pastikan konsumen toleran terhadap field dan nilai enum tak dikenal sebelum mengirim apa pun, dan lewati deprecation melalui freeze, announce, observe, dan remove dalam urutan itu.

Bagian sulit dari versioning bukan mengubah kode; melainkan bahwa Anda tidak bisa tahu siapa yang masih membaca field lama. Kompatibilitas bukan janji satu arah — ia adalah penyelarasan berkelanjutan antara penerbit dan konsumen yang tidak dikenal.

1. Batas kompatibilitas: penambahan tidak otomatis aman

Buku teks menyebut field opsional baru "kompatibel mundur", tetapi itu hanya berlaku ketika konsumen toleran terhadap field tak dikenal. Menambah field tetap merusak klien dalam setidaknya tiga situasi:

  • konsumen memvalidasi dengan schema yang menetapkan additionalProperties: false;
  • konsumen menelusuri object dalam urutan tetap, atau membaca nilai berdasarkan posisi alih-alih berdasarkan key;
  • field baru memakai nama lama tetapi mengubah maknanya, misalnya memperlebar status dari "status order" menjadi "status fulfillment".

Penambahan yang aman butuh tiga prasyarat: field opsional, maknanya mandiri, dan konsumen mengabaikan key tak dikenal. Pastikan ketiganya sebelum Anda bahkan membahas nomor versi.

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

Di sini refundDeadline dan channel adalah tambahan v2 yang opsional, sementara orderId dan status mempertahankan semantik aslinya. schemaVersion disimpan bersama dokumen alih-alih hanya hidup di header HTTP.

2. Perubahan yang memutus kompatibilitas dan tidak boleh dikirim

Perlakukan perubahan berikut sebagai garis merah yang membutuhkan versi baru atau transisi dual-write. Memperbarui dokumentasi tidak akan menyelamatkan Anda:

Perubahan Mengapa memutus kompatibilitas Alternatif yang bisa diterima
Menghapus field Konsumen langsung membaca undefined Deprecate, amati, lalu hapus setelah jendela berakhir
Mengubah tipe field "1" menjadi 1; klien bertipe kuat langsung gagal dan yang bertipe longgar sering gagal validasi Tambah nama field baru dan dual-write selama transisi
Menambah field wajib Pemanggil lama tidak mengirimnya, sehingga validasi selalu gagal Sediakan default, atau jadikan wajib hanya di versi baru
Mengubah himpunan nilai enum Pernyataan switch konsumen tidak punya cabang fallback Ajari klien menerima nilai tak dikenal dulu, lalu perluas enum
Mengubah makna field Tipe tetap valid sementara perilaku berubah — kasus tersulit untuk di-debug Perlakukan sebagai field baru dan bekukan semantik lama
Mengubah kedalaman nesting Setiap jalur akses harus berubah Pertahankan bentuk lama dan tambahkan field datar atau parameter expand

Mengubah makna adalah baris paling berbahaya: validasi schema tetap hijau, pemantauan tidak melaporkan error, dan hanya metrik bisnis yang melenceng. Perubahan makna apa pun sebaiknya dikirim sebagai field baru.

3. Di mana versi berada: dokumen, URL, media type

Tiga penempatan umum melayani tujuan berbeda:

Lokasi Contoh Cocok untuk Keterbatasan
Field dokumen "schemaVersion": 2 Basis data, message queue, backup offline Lapisan transport tidak bisa merutekan berdasarkan itu
Path URL /v2/orders/8842 API publik Mengikat alamat resource ke versi dan membuat migrasi mahal
Media type application/vnd.acme.order.v2+json Layanan internal yang butuh negosiasi konten Pemanggil harus menetapkan Accept dengan benar

Pembagian kerja yang praktis: gunakan negosiasi media-type di wire, dan selalu simpan field versi di dalam dokumen. Pesan lama di queue tidak akan naik versi karena Anda mengubah URL, begitu pula file backup. Hanya schemaVersion di dalam dokumen yang memberi fondasi untuk replay, backfill, dan audit.

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

Jika Anda memang mem-version URL, setidaknya biarkan path lama tetap mengembalikan bentuk lama sepanjang seluruh jendela deprecation alih-alih diam-diam mengarahkannya ke implementasi baru. Untuk keputusan kontrak API yang lebih luas, lihat Poin penting mendesain kontrak data JSON API.

4. Membuka jendela deprecation

Deprecation bukan satu pengumuman; ia adalah jendela dengan awal, periode pengamatan, dan akhir. Irama yang bisa diterapkan:

  1. Freeze: berhenti menulis field lama, tetapi tetap membacanya.
  2. Announce: tandai tanggal penghapusan di dokumentasi, changelog, dan header respons sekaligus.
  3. Observe: hitung pembacaan field lama per pemanggil sampai ia tetap nol selama dua periode berturut-turut.
  4. Remove: hanya setelah jendela ditutup, pembacaan nol, dan tes regresi lolos, hapus field dan kode kompatibilitasnya.

Header respons Sunset (RFC 8594) memberi klien tanggal penghapusan yang bisa dibaca mesin. Deprecation masih berupa draf IETF: bisa dipakai, tetapi jangan mengasumsikan setiap klien memahaminya. Pasangkan dengan header Link yang menunjuk ke panduan migrasi:

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

Panjang jendela adalah konvensi tim. Lantai yang umum adalah satu siklus rilis atau 90 hari, mana yang lebih lama. API publik sebaiknya mendapat lebih banyak, layanan internal bisa lebih sedikit, tetapi jangan pernah kurang dari waktu yang dibutuhkan setiap pemanggil untuk melakukan upgrade.

5. Menemukan konsumen sebelum deprecate

Sebelum deprecate, Anda harus bisa menjawab "siapa yang masih membaca ini?" Jika tidak, jendelanya adalah penipuan diri. Urutkan bukti berdasarkan keandalan:

  • Log gateway: agregasi akses field lama berdasarkan appId, User-Agent, dan versi klien.
  • Distributed trace: tandai field lama di lapisan serialisasi agar trace langsung menunjukkan nama layanan pemanggil.
  • Registry konsumen queue: daftar consumer group lebih andal daripada pencarian kode.
  • Pencarian repositori: rg hanya mencakup kode Anda sendiri; pemanggil eksternal tetap membutuhkan log.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

Temuan pencarian membuktikan "seseorang di repositori ini membacanya", bukan "tidak ada orang lain yang membacanya". Ekspor log gateway dari hari ke hari dan pastikan pembacaan field lama tetap nol sebelum menghapus apa pun.

6. Migrasi data: expand, backfill, contract

Parallel Change Martin Fowler menjelaskan tiga langkah: expand (field lama dan baru hidup berdampingan dan kedua versi bisa dibaca), backfill (pindahkan record historis ke bentuk baru), dan contract (hapus field lama dan cabang kompatibilitas). Jangan membalik urutannya, atau Anda tidak akan punya jalur rollback.

// Kerangka migrasi: cursor berdasarkan primary key, penulisan kondisional untuk idempotensi
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 };
}

Detail yang mudah terlewat:

  • Idempotensi: sertakan nomor versi lama di filter, agar menjalankan ulang tidak pernah menulis ulang record yang sudah dimigrasi.
  • Batching dan checkpoint: maju berdasarkan cursor _id, simpan checkpoint, dan lanjutkan dari sana setelah kegagalan.
  • Rehearsal: jalankan skrip terhadap snapshot produksi dan bandingkan jumlah record serta checksum sebelum dan sesudah.
  • Rollback: pertahankan field lama sampai field baru lolos validasi; ganti jalur baca dengan flag dan jangan pernah rollback hanya dengan mengedit schemaVersion.
  • Validasi: jalankan batasan bisnis setelah migrasi (jumlah uang non-negatif, transisi state legal), bukan sekadar pengecekan parse JSON.

Data konfigurasi mengikuti proses yang sama; lihat Mengelola JSON untuk konfigurasi, fixture, dan data lokal untuk cara mengatur file-file itu.

7. Contract test: taruh kompatibilitas di CI

Kompatibilitas tidak bisa bergantung pada ingatan code review; ia butuh tes. Dua lapisan sudah cukup:

  1. Shape test: validasi respons dengan JSON Schema, tetapi jangan menetapkan additionalProperties: false pada schema yang dipakai konsumen.
  2. Consumer-subset test: ekstrak field yang benar-benar diandalkan konsumen lama ke sub-schema dan assert bahwa respons baru masih memenuhinya.
// Contract test: respons baru harus tetap memenuhi subset konsumen lama
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 hanya mendeklarasikan field dan tipe yang dibaca konsumen lama dan membiarkan setiap field baru lewat. Itu menangkap field yang dihapus dan perubahan tipe tanpa alarm palsu pada penambahan. Simpan data kasus di direktori fixture, terurut dan bisa di-diff; untuk konflik merge di file-file itu, lihat Konflik Git file JSON: union menulis JSON rusak, merge berdasarkan key sebagai gantinya.

8. Peluncuran canary: skala berdasarkan dimensi, berhenti pada metrik

Masalah kompatibilitas jarang muncul di unit test; mereka muncul di canary. Perluas eksposur dari sempit ke luas:

Dimensi Praktik Apa yang divalidasi
Allowlist tenant Aktifkan jalur baca baru untuk pelanggan internal atau bersahabat dulu Perbedaan bentuk data dunia nyata
Versi klien Aktifkan hanya untuk klien yang sudah di-upgrade ke bentuk baru Klien lama dan baru berjalan berdampingan
Persentase trafik 5% → 25% → 100%, mengamati satu siklus bisnis penuh per langkah Panggilan long-tail dan perilaku cache hit

Pantau setidaknya tiga metrik: tingkat error API, tingkat kegagalan validasi kontrak, dan rasio baca antara field lama dan baru. Definisikan kondisi berhenti otomatis, seperti 5xx di atas 1% atau lonjakan mendadak pembacaan field lama, lalu rollback flag. Pertahankan dual write selama canary agar rollback hanya membutuhkan penggantian jalur baca, bukan perbaikan data.

9. Checklist kompatibilitas sebelum rilis

  • [ ] Field baru opsional, mandiri secara semantik, dan tidak memakai ulang nama lama.
  • [ ] Konsumen menangani field tak dikenal dan nilai enum tak dikenal dengan fallback.
  • [ ] Field versi disimpan bersama dokumen dan bisa dinegosiasikan di wire.
  • [ ] Jendela deprecation punya pengumuman, header Sunset, dan pengamatan.
  • [ ] Pembacaan field lama bisa dilacak ke pemanggil tertentu melalui log atau trace.
  • [ ] Skrip migrasi idempoten, terbatch, bisa dilanjutkan, dan punya jalur rollback.
  • [ ] Contract test mencakup subset konsumen lama dan berjalan di CI.
  • [ ] Canary punya dimensi eksposur eksplisit dan metrik berhenti.

FAQ

Apakah menambah field opsional selalu kompatibel mundur?

Tidak. Ia kompatibel hanya ketika konsumen mengabaikan field tak dikenal dan menghindari validasi schema ketat. Konsumen yang memakai additionalProperties: false atau membaca berdasarkan posisi tetap akan gagal. Verifikasi toleransi mereka dengan contract test sebelum merilis.

Apakah schemaVersion sebaiknya berada di URL atau di response body?

Wire bisa menegosiasikan versi melalui media type atau path URL, tetapi record yang disimpan harus membawa schemaVersion di dalam dokumen. Queue, backup offline, dan audit tidak pernah melewati URL, jadi hanya versi di dalam dokumen yang bisa mendukung replay dan backfill.

Bisakah field deprecated langsung dihapus?

Tidak. Berhenti menulisnya, umumkan tanggal penghapusan, dan hitung pembacaan per pemanggil. Hapus hanya setelah jendela ditutup dan pembacaan tetap nol. Sebelum menghapus, jalankan regresi penuh untuk memastikan tidak ada cron job atau laporan tersembunyi yang masih membacanya.

Bagaimana jika skrip migrasi gagal di tengah jalan?

Rancang sebagai job idempoten: update kondisional, cursor primary key, dan checkpoint. Lanjutkan dari checkpoint setelah kegagalan, dan menjalankan ulang tidak boleh menulis dua kali. Untuk rollback, pertahankan field lama dan snapshot; jangan hanya mengembalikan schemaVersion.

Bagaimana urutan migrasi data sebaiknya?

Ikuti Parallel Change: expand dulu agar field lama dan baru hidup berdampingan dan kedua versi bisa dibaca, backfill record historis ke bentuk baru, lalu contract dengan menghapus field lama dan cabang kompatibilitas. Jangan membalik urutannya, atau Anda kehilangan jalur rollback, dan buat skrip migrasi idempoten, terbatch, dan bisa dilanjutkan.