JSON Toolkit

Khi hai tài liệu JSON khác nhau: diff theo vị trí, diff theo id, hay chuẩn hoá trước

Kết luận: Năm mươi bản ghi thành năm mươi mốt và diff báo năm mươi mốt thay đổi. Bài viết nói về dương tính giả của text-diff, cách khớp mảng theo id, chuẩn hoá bằng jq và các patch khả dụng.

Người mớiĐọc khoảng 6 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa so sánh hai tài liệu JSON

Tóm lại: chuẩn hoá trước, rồi mới so sánh — dùng jq -S hoặc tuần tự hoá ổn định để xoá bỏ thứ tự khoá, thụt lề và cách viết số, sắp xếp hoặc đánh chỉ mục mảng theo một khoá nghiệp vụ khi thứ tự không có nghĩa, và chỉ sau đó mới biến diff thành patch.

Khác biệt JSON không phải khác biệt văn bản. So sánh các tệp như chuỗi thì một lần đổi thứ tự khoá sẽ làm cả trang sáng đèn; so sánh mảng theo chỉ số thì chèn một bản ghi sẽ đánh dấu năm mươi bản ghi còn lại là đã thay đổi.

Một patch được phát hành đến dịch vụ đơn hàng, và bạn chỉ có hai phản hồi: năm mươi bản ghi trước, năm mươi mốt sau, với một bản ghi mới được chèn vào giữa. Công cụ diff báo năm mươi mốt thay đổi, một bức tường đỏ, và bạn vẫn không biết bản ghi nào thực sự đã đổi.

Công cụ không hỏng. "So sánh" mang ba nghĩa khác nhau trên JSON, và phần lớn người ta mặc định chọn cách ít hữu ích nhất.

Tái hiện báo động giả: một lần chèn, năm mươi mốt thay đổi

Ba mươi dòng Node 22: dựng năm mươi đơn hàng ({id, sku, qty}), rồi thêm một cái vào cuối và chèn một cái vào đầu, đếm khác biệt bằng cách căn theo từng chỉ số.

Thao tác Số bản ghi Thay đổi báo theo vị trí
Thêm một ở cuối 50 → 51 1
Chèn một ở đầu 50 → 51 51

Cùng là "một bản ghi thêm", nhưng nhiễu gấp năm mươi mốt lần. Lý do rất tầm thường: khi so sánh theo chỉ số, phần tử mới rơi vào vị trí 0 và mọi bản ghi hiện có dịch xuống một ô. Vị trí 0 so với vị trí 0, vị trí 1 so với vị trí 1 — không cặp nào bằng nhau.

Thay vào đó, dựng một Map với khoá là id thì cùng dữ liệu đó chỉ báo đúng một mục: $[id=ORD-999] added.

Thí nghiệm đó cho bạn quy tắc quan trọng hơn mọi khuyến nghị công cụ: mảng là một dãy có thứ tự hay một tập không thứ tự sẽ quyết định cách so sánh bạn nên dùng.

Ba cách một text diff nói dối về JSON

diff a.json b.json so sánh ký tự. JSON mang một cấu trúc dữ liệu. Sự lệch nhau đó tạo ra ba loại dương tính giả rất chắc chắn.

1. Thứ tự khoá của object bị đảo

Mục 4 của RFC 8259 nói rõ: một object là "một tập không thứ tự gồm các cặp tên/giá trị". Hai tài liệu sau là cùng một giá trị:

{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }

Diff theo dòng gọi cả ba dòng là đã thay đổi. Tôi đảo thứ tự khoá của một object mười khoá, in đẹp cả hai, rồi so sánh từng dòng: cả mười dòng bị đánh dấu, trong khi so sánh theo cấu trúc nói chúng bằng nhau. Tỷ lệ nhiễu 10:0.

Vì sao thứ tự khoá lại thay đổi chứ? Đổi bộ tuần tự hoá, chạy formatter có sắp xếp khoá, dựng lại object từ dictionary hay hash map, chuyển giữa các runtime ngôn ngữ — chỉ cần một trong những điều đó là đủ khiến dữ liệu giống hệt nhau trông khác nhau trên đĩa.

2. Khoảng trắng và thụt lề

{"a":1} và {\n "a": 1\n} phân tích thành cùng một giá trị. Một text diff thấy xuống dòng và dấu cách rồi đánh dấu tất cả.

3. Cách viết khác nhau của số và escape

1.0 và 1, 1e2 và 100, "\u00e9" và "é" — cùng giá trị sau khi phân tích, khác văn bản sau khi tuần tự hoá. Trường hợp này nhẹ hơn vì chỉ chạm vài dòng, nhưng trong review cấu hình thì đủ khiến bạn tưởng một con số đã đổi.

Trường hợp ngược lại là thứ văn bản không thấy còn cấu trúc thì thấy: 5 so với "5". Chỉ thêm một cặp dấu ngoặc kép, nhưng kiểu dữ liệu đã trôi — thường là lỗi chứ không phải cập nhật. So sánh theo cấu trúc báo nó là modify không chút do dự.

Ba chế độ so sánh, ba nhiệm vụ

Chế độ So sánh cái gì Nhiễu đến từ đâu Đầu ra Phù hợp
Text diff Dòng ký tự Thứ tự khoá, thụt lề, cách viết số Số dòng, đỏ/xanh Gói bản địa hoá, fixture có thứ tự khoá ổn định
Structural diff Cây đã phân tích, căn theo đường dẫn Mảng căn theo chỉ số Đường dẫn kèm add/remove/modify Phản hồi API, cấu hình, webhook
Semantic diff Cây cộng khoá nghiệp vụ Chỉ do chọn khoá tồi Đường dẫn kèm phát hiện move/insert Danh sách bản ghi, đơn hàng, tồn kho

Structural diff giải quyết tầng "thứ tự khoá và định dạng". Semantic diff đi thêm một bước và giải quyết "danh tính phần tử mảng". Khác biệt không nằm ở độ phức tạp triển khai; nó nằm ở việc bạn có sẵn lòng nói cho công cụ biết trường nào là "hộ chiếu" của một bản ghi hay không.

Mảng là đường phân chia

Hãy hỏi một câu: thứ tự của mảng này có phải chính là dữ liệu không?

  • Thứ tự là dữ liệu: các bước di trú, xếp hạng tìm kiếm, điểm trên biểu đồ, chuỗi phê duyệt. Đổi vị trí là đổi hành vi, nên so sánh theo vị trí là đúng.
  • Thứ tự vô nghĩa: tag, tập ID quyền, dòng hàng trong đơn, danh sách tồn kho. Một lần chèn không nên ảnh hưởng đến cách đánh giá các mục khác.

Khi thứ tự vô nghĩa, hãy đánh chỉ mục bản ghi theo khoá thay vì đập chúng theo vị trí:

// Match by id: an insert or delete reports one entry, other items stay put
function diffById(before, after, path = '$') {
  const out = [];
  const beforeMap = new Map(before.map((item) => [item.id, item]));
  const afterMap = new Map(after.map((item) => [item.id, item]));

  for (const [id, value] of afterMap) {
    if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
  }
  for (const [id, value] of beforeMap) {
    if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
    else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
      out.push({ path: `${path}[id=${id}]`, type: 'changed' });
    }
  }
  return out;
}

Các thư viện trưởng thành biến điều này thành một tuỳ chọn. jsondiffpatch dùng LCS để tìm dãy con chung dài nhất, nhưng phần tử object bên trong mảng mặc định được khớp theo vị trí — tài liệu chính thức nhấn mạnh rằng bạn phải cung cấp hàm objectHash để khớp chúng theo nội dung. Người mới dùng thường kết luận thư viện này ngốc; thực ra họ chưa bao giờ truyền khoá.

Chọn khoá cũng quan trọng. id là lựa chọn an toàn. sku chỉ hiệu quả khi SKU là duy nhất. Băm cả object biến "một trường đổi" thành "một cái bị xoá cộng một cái được thêm", còn tệ hơn so sánh theo vị trí.

Mẹo terminal nhanh nhất: chuẩn hoá bằng jq

Nếu không muốn dùng thư viện và chỉ cần xem qua, hãy chuẩn hoá trước rồi diff sau. jq -S sắp xếp khoá object một cách đệ quy và áp dụng thụt lề nhất quán:

# Normalize, then compare line by line; key order and indentation noise disappear
diff -u <(jq -S . before.json) <(jq -S . after.json)

# When the array is genuinely unordered, sort by key before comparing
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
        <(jq -S '.items |= sort_by(.id)' after.json)

Tôi chạy printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . và nhận {"a":1,"arr":[1,2],"b":2} — khoá đã sắp xếp, arr giữ nguyên. Hãy nhớ điều này: -S chỉ sắp xếp khoá object, không bao giờ sắp xếp phần tử mảng. Hoặc sắp xếp mảng tường minh bằng sort / sort_by, hoặc chấp nhận nhiễu theo vị trí.

Hai cái bẫy:

  • jq không sửa tại chỗ; nó ghi ra đầu ra chuẩn. Hãy dùng process substitution (<(...)), hoặc ghi tệp tạm, kiểm tra, rồi thay thế;
  • -r bóc dấu ngoặc kép khỏi chuỗi, nên đầu ra có thể chứa xuống dòng hoặc ký tự có nghĩa với shell. Hãy đặt ngoặc kép cho dữ liệu không tin cậy trước khi đẩy tiếp qua ống.

Công cụ /diff của trang này làm gì và không làm gì

Công cụ so sánh JSON tích hợp sẵn đi theo cùng cách tiếp cận cấu trúc: cả hai phía được phân tích thành cây và căn đệ quy theo đường dẫn, sinh ra path + type(add/remove/modify) + before/after. Thứ tự khoá object không ảnh hưởng đến kết quả, và mọi thứ chạy trong trình duyệt — payload được dán vào không bao giờ bị tải lên.

Tuy vậy, mảng của nó được căn theo chỉ số. Đây là đánh đổi có chủ ý, không phải khuyết điểm:

  • Thêm ở cuối, sửa cùng vị trí, hoặc thay thế toàn bộ cho bạn đầu ra sạch;
  • Chèn một bản ghi ở đầu hoặc ở giữa sẽ đánh dấu mọi thứ phía sau là modify, và bạn phải tự nhận ra "thực ra chỉ có một bản ghi là mới".

Vậy nên khi so sánh danh sách đơn hàng, động tác đúng là sắp xếp cả hai phía theo id trước khi dán. Đầu vào đã sắp xếp ghim điểm chèn và thu nhiễu về một mục. Không cần cài đặt gì, và nó bao phủ phần lớn tình huống hằng ngày.

Nếu bạn cần "sinh một patch khả dụng" hoặc "phát hiện phần tử bị di chuyển", công cụ trong trang không làm điều đó. Đó là phần tiếp theo.

Biến diff thành patch: hai định dạng, đừng trộn

Con người đọc diff; chương trình áp dụng patch. JSON có hai định dạng patch với ngữ nghĩa hoàn toàn khác nhau.

Khía cạnh JSON Patch (RFC 6902) JSON Merge Patch (RFC 7396)
Hình dạng Mảng thao tác với op, path, value Một object JSON có hình dạng giống tài liệu đích
Mảng Có, theo chỉ số, /items/0, /- để nối thêm Không, mảng bị thay thế toàn bộ
Xoá một khoá remove Chỉ qua null, mà điều này không xoá được trường có giá trị null hợp lệ
Biểu đạt di chuyển move / copy Không
Phù hợp Tài nguyên chứa mảng, log kiểm toán, cập nhật nguyên tử Cập nhật từng phần của object phẳng, phân phối cấu hình

JSON Patch có một thao tác mà người ta hay bỏ qua: test.

[
  { "op": "test", "path": "/version", "value": 3 },
  { "op": "replace", "path": "/status", "value": "shipped" }
]

Nếu test thất bại, toàn bộ patch bị từ chối. Đó là khoá lạc quan được xây thẳng vào giao thức — không cần thêm header If-Match để bảo đảm "tôi chỉ ghi vì tôi đã thấy phiên bản 3". Với một endpoint PATCH, điều này đáng giá hơn mọi quy ước tài liệu.

Đường dẫn dùng JSON Pointer (RFC 6901): ~0 escape ~, ~1 escape /. Khoá chứa dấu gạch chéo (ví dụ kiểu MIME làm khoá) phải được escape, nếu không pointer sẽ trỏ sang chỗ khác. Chi tiết nằm trong JSON Pointer và Patch.

Còn một vấn đề thực tế: patch sinh tự động từ diff thường địa chỉ mảng theo chỉ số. Chỉ số rất mong manh trên production — một lần chèn đồng thời là /items/3 không còn trỏ đúng bản ghi đó. Hoặc giới hạn patch sinh ra chỉ ở trường object, hoặc kiểm chứng các trường then chốt bằng test trước khi áp dụng.

Đưa so sánh vào CI: khẳng định giá trị, không khẳng định văn bản

Ba quy tắc là đủ:

  1. Khẳng định giá trị đã phân tích trong kiểm thử, không bao giờ khẳng định đầu ra của JSON.stringify. Trong Jest dùng toEqual; trong Python dùng assert a == b sau json.loads. Thứ tự khoá và thụt lề không tham gia.
  2. Đưa trường động vào danh sách trắng. requestId, timestamp, traceId khác nhau mỗi lần chạy; không loại trừ thì kiểm thử đỏ vĩnh viễn. propertyFilter trong jsondiffpatch và exclude_paths trong deepdiff tồn tại vì việc này.
  3. In đường dẫn khi thất bại, không in cả tài liệu. Không ai đọc 5 MB payload trong log; $.data.items[id=ORD-042].qty: 2 → 3 định vị vấn đề tức thì.

Cấu hình trôi dạt cũng vậy: jq -S cả cấu hình staging lẫn production, sắp xếp mảng theo một khoá nghiệp vụ, và những gì còn lại là khác biệt thật. Loại vấn đề này — cùng dữ liệu trông khác nhau giữa các môi trường — thường là do hợp đồng chưa được định nghĩa, và đó là nội dung của thiết kế hợp đồng API.

FAQ

Thứ tự khoá có tính là khác biệt không?

Không. RFC 8259 định nghĩa object là tập không thứ tự, nên {"a":1,"b":2} và {"b":2,"a":1} là cùng một giá trị. Công cụ nào báo việc đảo thứ tự là thay đổi thì đang so sánh văn bản, không phải so sánh JSON.

Vì sao chèn một bản ghi lại đánh dấu mọi mục là đã thay đổi?

Vì công cụ căn mảng theo từng chỉ số. Phần tử mới nằm ở vị trí 0, mọi bản ghi hiện có dịch xuống một, và không cặp vị trí nào khớp. Thay vào đó, hãy sắp xếp hoặc đánh chỉ mục theo một khoá như id trước khi so sánh, đừng so sánh theo vị trí.

Các công cụ so sánh trực tuyến có tải dữ liệu của tôi lên không?

Tuỳ vào cách triển khai. Công cụ kết xuất phía server gửi payload tới backend, nơi nó có thể bị ghi log; công cụ phía client phân tích trong trình duyệt và không tải gì lên. Trước khi so sánh cấu hình production hay payload người dùng, hãy xác nhận trang tính toán cục bộ, hoặc ngắt mạng rồi thử lại.

Có thể dùng thẳng kết quả diff làm patch không?

Không trực tiếp. Diff nói chỗ nào khác nhau; JSON Patch nói phải chạy những thao tác nào theo thứ tự. Giữa chúng cần một lớp chuyển đổi. Patch sinh ra cũng thường địa chỉ mảng theo chỉ số, điều này hỏng dưới điều kiện đồng thời — hãy thêm thao tác test trước khi áp dụng.

Làm sao so sánh những tệp hàng chục megabyte?

Hãy stream trước (jq -c ghi mỗi bản ghi một dòng), hoặc chỉ trích cây con bạn quan tâm, như jq '.data.items'. So sánh đệ quy toàn bộ hàng chục megabyte vẫn vừa bộ nhớ; vượt quá một trăm megabyte thì hãy chia nhỏ hoặc đánh chỉ mục theo khoá. Về số liệu bộ nhớ và thời gian cụ thể, xem hiệu năng JSON lớn.

Có nên sắp xếp mảng theo id trước khi so sánh không?

Có. Công cụ so sánh JSON tích hợp sẵn căn mảng theo chỉ số, nên chèn một bản ghi ở đầu hoặc ở giữa sẽ đánh dấu mọi thứ phía sau là modify; sắp xếp cả hai phía theo id trước khi dán sẽ ghim điểm chèn và thu nhiễu về một mục. Ngoại lệ là khi thứ tự mảng chính là dữ liệu — các bước di trú, xếp hạng tìm kiếm, điểm trên biểu đồ — lúc đó so sánh theo vị trí là đúng còn sắp xếp là sai.