JSON Toolkit

Phiên bản hoá hợp đồng JSON: tương thích, ngừng hỗ trợ và di trú dữ liệu

Kết luận: Thêm một trường không tự động là an toàn. Bài viết nói về ranh giới tương thích, trường phiên bản, cửa sổ ngừng hỗ trợ, di trú ghi kép, kiểm thử hợp đồng và số liệu canary.

Nâng caoĐọc khoảng 10 phútXuất bản 2026-10-31Cập nhật 2026-10-31
Ảnh bìa phiên bản hoá hợp đồng JSON, tương thích và di trú dữ liệu

Tóm lại: hãy chạy tiến hoá phiên bản theo trình tự mở rộng, lấp đầy dữ liệu, rồi thu hẹp, và lưu phiên bản bên trong tài liệu; xác nhận người tiêu thụ chịu được trường và giá trị enum chưa biết trước khi phát hành bất cứ thứ gì, và thực hiện việc ngừng hỗ trợ theo đúng thứ tự đóng băng, thông báo, quan sát và loại bỏ.

Phần khó của phiên bản hoá không phải là đổi mã; nó là bạn không thể biết ai còn đọc trường cũ. Tương thích không bao giờ là một lời hứa một chiều — nó là sự căn chỉnh liên tục giữa bên phát hành và những người tiêu thụ chưa biết danh tính.

1. Ranh giới tương thích: thêm vào không tự động là an toàn

Sách giáo khoa gọi một trường tuỳ chọn mới là "tương thích ngược", nhưng điều đó chỉ đúng khi người tiêu thụ chịu được trường chưa biết. Việc thêm trường vẫn phá hỏng client trong ít nhất ba tình huống:

  • người tiêu thụ kiểm chứng bằng schema có đặt additionalProperties: false;
  • người tiêu thụ duyệt object theo thứ tự cố định, hoặc đọc giá trị theo vị trí thay vì theo khoá;
  • trường mới dùng lại tên cũ nhưng đổi ý nghĩa, ví dụ mở rộng status từ "trạng thái đơn hàng" sang "trạng thái hoàn tất".

Một lần thêm an toàn cần ba điều kiện tiên quyết: trường là tuỳ chọn, ý nghĩa độc lập, và người tiêu thụ bỏ qua khoá chưa biết. Hãy xác nhận cả ba trước khi bàn đến số phiên bản.

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

Ở đây refundDeadline và channel là các bổ sung tuỳ chọn của v2, còn orderId và status giữ nguyên ngữ nghĩa ban đầu. schemaVersion được lưu cùng tài liệu thay vì chỉ tồn tại trong một header HTTP.

2. Những thay đổi phá vỡ mà bạn không thể phát hành

Hãy coi những thay đổi sau là ranh giới đỏ, cần phiên bản mới hoặc giai đoạn ghi kép. Cập nhật tài liệu sẽ không cứu bạn:

Thay đổi Vì sao phá vỡ tương thích Phương án chấp nhận được
Xoá một trường Người tiêu thụ đọc undefined ngay lập tức Ngừng hỗ trợ, quan sát, rồi xoá sau cửa sổ
Đổi kiểu của một trường "1" thành 1; client định kiểu mạnh hỏng ngay, client lỏng thường hỏng kiểm chứng Thêm tên trường mới và ghi kép trong giai đoạn chuyển tiếp
Thêm một trường bắt buộc Người gọi cũ không gửi, nên kiểm chứng luôn thất bại Cung cấp giá trị mặc định, hoặc chỉ bắt buộc ở phiên bản mới
Đổi tập giá trị enum Câu switch của người tiêu thụ thiếu nhánh dự phòng Dạy client chấp nhận giá trị chưa biết trước, rồi mới mở rộng enum
Đổi ý nghĩa trường Kiểu vẫn hợp lệ trong khi hành vi đổi — trường hợp khó gỡ lỗi nhất Coi đó là trường mới và cố định ngữ nghĩa cũ
Đổi độ sâu lồng Mọi đường truy cập đều phải đổi Giữ hình dạng cũ và thêm trường phẳng hoặc tham số expand

Đổi ý nghĩa là dòng nguy hiểm nhất: kiểm chứng schema vẫn xanh, giám sát không báo lỗi, chỉ số liệu nghiệp vụ trôi đi. Mọi thay đổi về ý nghĩa đều nên được phát hành như một trường mới.

3. Phiên bản nằm ở đâu: tài liệu, URL, media type

Ba vị trí phổ biến phục vụ những mục đích khác nhau:

Vị trí Ví dụ Phù hợp với Hạn chế
Trường trong tài liệu "schemaVersion": 2 Cơ sở dữ liệu, hàng đợi tin nhắn, bản sao lưu offline Tầng vận chuyển không định tuyến dựa trên nó
Đường dẫn URL /v2/orders/8842 API công khai Gắn địa chỉ tài nguyên với phiên bản và khiến di trú tốn kém
Media type application/vnd.acme.order.v2+json Dịch vụ nội bộ cần thương lượng nội dung Người gọi phải đặt Accept đúng

Một cách phân chia thực tế: dùng thương lượng media type trên đường truyền, và luôn lưu trường phiên bản trong tài liệu. Tin nhắn cũ trong hàng đợi sẽ không nâng cấp vì bạn đổi một URL, và tệp sao lưu cũng vậy. Chỉ schemaVersion trong tài liệu mới cho việc phát lại, lấp đầy dữ liệu và kiểm toán một nền tảng.

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

Nếu bạn thật sự đặt phiên bản vào URL, ít nhất hãy giữ đường dẫn cũ trả về hình dạng cũ trong toàn bộ cửa sổ ngừng hỗ trợ, thay vì âm thầm trỏ nó sang cách triển khai mới. Về các quyết định hợp đồng API rộng hơn, xem Những điểm then chốt khi thiết kế hợp đồng dữ liệu API JSON.

4. Mở cửa sổ ngừng hỗ trợ

Ngừng hỗ trợ không phải một thông báo đơn lẻ; nó là một cửa sổ có điểm mở đầu, giai đoạn quan sát và điểm kết thúc. Một nhịp điệu khả dụng:

  1. Đóng băng: ngừng ghi trường cũ, nhưng vẫn đọc nó.
  2. Thông báo: ghi ngày loại bỏ vào tài liệu, changelog, và đồng thời một header phản hồi.
  3. Quan sát: đếm lượt đọc trường cũ theo từng người gọi cho đến khi nó giữ ở mức không qua hai kỳ liên tiếp.
  4. Loại bỏ: chỉ sau khi cửa sổ đóng, lượt đọc bằng không và kiểm thử hồi quy qua, mới xoá trường và mã tương thích của nó.

Header phản hồi Sunset (RFC 8594) cho client một ngày loại bỏ đọc được bằng máy. Deprecation vẫn là dự thảo IETF: dùng được, nhưng đừng cho rằng mọi client đều hiểu nó. Hãy ghép nó với một header Link trỏ đến hướng dẫn di trú:

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

Độ dài cửa sổ là quy ước của nhóm. Mức sàn phổ biến là một chu kỳ phát hành hoặc 90 ngày, tuỳ mức nào dài hơn. API công khai nên được nhiều hơn, dịch vụ nội bộ có thể ít hơn, nhưng không bao giờ ít hơn thời gian mọi người gọi cần để nâng cấp.

5. Tìm người tiêu thụ trước khi ngừng hỗ trợ

Trước khi ngừng hỗ trợ, bạn phải trả lời được "ai còn đọc cái này?" Nếu không, cửa sổ chỉ là tự lừa mình. Hãy xếp bằng chứng theo độ tin cậy:

  • Log gateway: tổng hợp truy cập trường cũ theo appId, User-Agent và phiên bản client.
  • Trace phân tán: gắn thẻ trường cũ trong tầng tuần tự hoá để một trace hiện thẳng tên dịch vụ đang gọi.
  • Sổ đăng ký người tiêu thụ hàng đợi: danh sách nhóm người tiêu thụ đáng tin hơn tìm kiếm mã.
  • Tìm kiếm kho mã: rg chỉ bao phủ mã của chính bạn; người gọi bên ngoài vẫn cần log.
rg --type ts --type js 'legacyStatus|statusCode' src/ --stats

Một kết quả tìm kiếm chứng minh "ai đó trong kho mã này có đọc nó", không chứng minh "không ai khác đọc". Hãy xuất log gateway theo từng ngày và xác nhận lượt đọc trường cũ đã giữ ở mức không trước khi xoá bất cứ thứ gì.

6. Di trú dữ liệu: mở rộng, lấp đầy, thu hẹp

Parallel Change của Martin Fowler mô tả ba bước: mở rộng (trường cũ và mới cùng tồn tại và cả hai phiên bản đều đọc được), lấp đầy (chuyển bản ghi lịch sử sang hình dạng mới), và thu hẹp (xoá trường cũ cùng các nhánh tương thích). Đừng đảo thứ tự, nếu không bạn sẽ không có đường quay lui.

// Migration skeleton: cursor by primary key, conditional writes for idempotency
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 };
}

Những chi tiết dễ bị bỏ qua:

  • Tính bất biến (idempotent): đưa số phiên bản cũ vào filter, để chạy lại không bao giờ ghi đè một bản ghi đã di trú.
  • Chia lô và điểm kiểm tra: tiến theo con trỏ _id, lưu điểm kiểm tra, và tiếp tục từ đó sau khi thất bại.
  • Diễn tập: chạy script trên một ảnh chụp production và so sánh số bản ghi cùng checksum trước và sau.
  • Quay lui: giữ trường cũ cho đến khi trường mới qua kiểm chứng; chuyển đường đọc bằng một cờ và không bao giờ quay lui bằng cách chỉ sửa schemaVersion.
  • Kiểm chứng: chạy ràng buộc nghiệp vụ sau di trú (số tiền không âm, chuyển trạng thái hợp lệ), không chỉ kiểm tra JSON phân tích được.

Dữ liệu cấu hình theo cùng quy trình; xem Quản lý JSON cho cấu hình, fixture và dữ liệu cục bộ để biết cách tổ chức những tệp đó.

7. Kiểm thử hợp đồng: đưa tương thích vào CI

Tương thích không thể dựa vào trí nhớ khi review mã; nó cần kiểm thử. Hai tầng là đủ:

  1. Kiểm thử hình dạng: kiểm chứng phản hồi bằng JSON Schema, nhưng không đặt additionalProperties: false trên schema mà người tiêu thụ dùng.
  2. Kiểm thử tập con của người tiêu thụ: trích những trường mà người tiêu thụ cũ thật sự dựa vào thành một schema con và khẳng định phản hồi mới vẫn thoả nó.
// Contract test: new responses must still satisfy the old consumer subset
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));
});

Schema con chỉ khai báo những trường và kiểu mà người tiêu thụ cũ đọc và cho mọi trường mới đi qua. Cách này bắt được trường bị xoá và kiểu bị đổi mà không báo động giả với phần bổ sung. Giữ dữ liệu ca trong một thư mục fixture, có thứ tự và so sánh diff được; về xung đột merge trong những tệp đó, xem Xung đột Git trên tệp JSON: union ghi ra JSON hỏng, hãy merge theo khoá.

8. Triển khai canary: mở rộng theo chiều, dừng theo số liệu

Vấn đề tương thích hiếm khi lộ ra trong kiểm thử đơn vị; chúng lộ ra trong canary. Hãy mở rộng phạm vi từ hẹp đến rộng:

Chiều Cách làm Nó kiểm chứng điều gì
Danh sách trắng tenant Bật đường đọc mới cho khách nội bộ hoặc khách thân thiết trước Khác biệt hình dạng dữ liệu thực tế
Phiên bản client Chỉ bật cho client đã nâng cấp lên hình dạng mới Client cũ và mới chạy song song
Phần trăm lưu lượng 5% → 25% → 100%, quan sát một chu kỳ nghiệp vụ đầy đủ mỗi bước Lượt gọi đuôi dài và hành vi cache hit

Theo dõi ít nhất ba số liệu: tỷ lệ lỗi API, tỷ lệ thất bại kiểm chứng hợp đồng, và tỷ lệ đọc giữa trường cũ và mới. Định nghĩa điều kiện dừng tự động, như 5xx trên 1% hoặc lượt đọc trường cũ đột ngột tăng lại, và tắt cờ. Giữ ghi kép trong suốt canary để quay lui chỉ cần chuyển đường đọc, không cần sửa dữ liệu.

9. Danh sách kiểm tra tương thích trước khi phát hành

  • [ ] Trường mới là tuỳ chọn, độc lập về ngữ nghĩa, và không dùng lại tên cũ.
  • [ ] Người tiêu thụ xử lý trường chưa biết và giá trị enum chưa biết bằng một nhánh dự phòng.
  • [ ] Trường phiên bản được lưu cùng tài liệu và có thể thương lượng trên đường truyền.
  • [ ] Cửa sổ ngừng hỗ trợ có thông báo, header Sunset, và quan sát.
  • [ ] Lượt đọc trường cũ có thể truy vết đến người gọi cụ thể qua log hoặc trace.
  • [ ] Script di trú bất biến, chia lô, tiếp tục được, và có đường quay lui.
  • [ ] Kiểm thử hợp đồng bao phủ tập con người tiêu thụ cũ và chạy trong CI.
  • [ ] Canary có chiều mở rộng và số liệu dừng tường minh.

FAQ

Thêm một trường tuỳ chọn có luôn tương thích ngược không?

Không. Nó chỉ tương thích khi người tiêu thụ bỏ qua trường chưa biết và tránh kiểm chứng schema nghiêm ngặt. Người tiêu thụ dùng additionalProperties: false hoặc đọc theo vị trí vẫn sẽ hỏng. Hãy xác minh khả năng chịu đựng của họ bằng kiểm thử hợp đồng trước khi phát hành.

schemaVersion nên nằm trong URL hay trong body phản hồi?

Đường truyền có thể thương lượng phiên bản qua media type hoặc đường dẫn URL, nhưng bản ghi đã lưu phải mang schemaVersion bên trong tài liệu. Hàng đợi, bản sao lưu offline và kiểm toán không bao giờ đi qua URL, nên chỉ phiên bản trong tài liệu mới hỗ trợ phát lại và lấp đầy dữ liệu.

Có thể xoá ngay một trường đã ngừng hỗ trợ không?

Không. Hãy ngừng ghi nó, thông báo ngày loại bỏ, và đếm lượt đọc theo từng người gọi. Chỉ xoá sau khi cửa sổ đóng và lượt đọc giữ ở mức không. Trước khi xoá, chạy hồi quy đầy đủ để xác nhận không còn tác vụ cron hay báo cáo ẩn nào đọc nó.

Nếu script di trú thất bại giữa đường thì sao?

Hãy thiết kế nó như một công việc bất biến: cập nhật có điều kiện, con trỏ khoá chính, và điểm kiểm tra. Sau thất bại, tiếp tục từ điểm kiểm tra, và các lần chạy lặp lại không được ghi hai lần. Để quay lui, giữ trường cũ và một ảnh chụp; đừng chỉ hoàn nguyên schemaVersion.

Nên sắp xếp một cuộc di trú dữ liệu như thế nào?

Theo Parallel Change: mở rộng trước để trường cũ và mới cùng tồn tại và cả hai phiên bản đều đọc được, lấp đầy bản ghi lịch sử sang hình dạng mới, rồi thu hẹp bằng cách xoá trường cũ và các nhánh tương thích. Đừng đảo thứ tự, nếu không bạn mất đường quay lui, và hãy làm script di trú bất biến, chia lô và tiếp tục được.