JSON Toolkit

JSON Patch trên production: tính nguyên tử, tính bất biến và xung đột đồng thời

Kết luận: JSON Patch chỉ là một định dạng thay đổi; tính nguyên tử, tính bất biến và điều kiện đồng thời vẫn là việc của bạn. Bài viết nói về điều kiện tiên quyết bằng test, escape đường dẫn, quay lui và phối hợp ETag.

Nâng caoĐọc khoảng 10 phútXuất bản 2026-10-31Cập nhật 2026-10-31
Ảnh bìa JSON Patch trên production

Tóm lại: hãy kiểm chứng đường dẫn đích và quyền của patch trước, rồi thêm khẳng định đồng thời bằng test hoặc If-Match; áp dụng patch trên một bản sao để thất bại không để lại gì viết dở.

Nguy hiểm thật sự của một request PATCH không phải là ghi sai trường. Nó là tin rằng việc ghi đã thành công trong khi âm thầm đè lên thay đổi của người khác. JSON Patch cho bạn công cụ, nhưng thiết kế an toàn vẫn là của bạn.

1. Ôn nhanh tập thao tác RFC 6902

JSON Patch mô tả thay đổi bằng một mảng thao tác, với media type application/json-patch+json. Sáu thao tác thực thi theo thứ tự mảng:

Thao tác Tác dụng Ràng buộc then chốt
add Thêm một member hoặc chèn một phần tử mảng Chỉ số mảng có thể bằng độ dài; - nghĩa là cuối
remove Xoá một member hoặc phần tử Đường dẫn đích phải tồn tại
replace Thay một giá trị đã có Đường dẫn đích phải tồn tại
move Di chuyển một nút Không thể di chuyển một nút vào đường dẫn con của chính nó
copy Sao chép một nút Đường dẫn nguồn phải tồn tại
test Khẳng định giá trị bằng nhau Nếu thất bại, cả patch không được áp dụng

Nhiều người chỉ nhớ ba thao tác đầu và quên rằng test chính là thứ biến một lần ghi mù thành một lần ghi có điều kiện. Ngữ nghĩa đầy đủ của các thao tác và ranh giới mảng được trình bày trong JSON Pointer, Patch và Merge Patch.

2. Dùng test để biến patch thành cập nhật có điều kiện

Một patch khoá lạc quan điển hình không bắt đầu bằng một lần ghi, mà bằng một khẳng định phiên bản:

{"op":"test","path":"/version","value":1}

Đưa nó vào một patch hoàn chỉnh:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

Logic là: xác nhận phiên bản tài liệu hiện tại là 1, rồi đổi thành 2. Nếu một request khác đã đẩy phiên bản lên 2, test đầu tiên thất bại, cả patch không được áp dụng, và server trả về xung đột. Điều này ngăn mất cập nhật mà không cần đưa vào một khoá phân tán.

Vài điểm thiết kế:

  • Đặt test trước mọi lần ghi. Nếu một lần ghi trước đã có hiệu lực, khẳng định đó vô nghĩa;
  • Khẳng định trên số phiên bản hoặc digest do server sinh, không phải trên một trường thường mà client tự do thay đổi;
  • Tăng phiên bản sau mỗi lần ghi thành công; nếu không, hai request dùng cùng phiên bản cũ đều qua;
  • value của test phải có đúng cùng kiểu JSON như giá trị tài liệu. 1 và "1" không bằng nhau.

3. Escape đường dẫn và thăm nhiễm prototype

Quy tắc escape JSON Pointer đơn giản: viết ~ thành ~0 và / thành ~1, và thay ~ trước /, nếu không bạn sẽ escape kép.

Key "a/b"    → /a~1b
Key "m~n"    → /m~0n
Key "a~/b"   → /a~0~1b

Rủi ro với patch bên ngoài thường không phải escape mà là khoá nguy hiểm. Trong JavaScript, những khoá như __proto__, constructor và prototype có thể kích hoạt thăm nhiễm prototype, kể cả qua một lệnh add thông thường:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

Hãy dùng danh sách trắng thay vì danh sách đen:

  1. Chỉ cho patch sửa các trường nghiệp vụ, như /status hay /items/0/quantity;
  2. Kiểm chứng mọi path và from theo biểu thức chính quy hoặc tiền tố đường dẫn, từ chối những cấp không mong đợi ngoài chỉ số mảng;
  3. Phân tích JSON vào container Object.create(null) hoặc Map để giảm tác động của chuỗi prototype bị thăm nhiễm;
  4. Đặt trần số thao tác và kích thước byte của mỗi value.

4. Áp dụng nguyên tử và quay lui

RFC 6902 yêu cầu thực thi nguyên tử, nhưng tính nguyên tử là một yêu cầu ngữ nghĩa, không phải mặc định trong mọi thư viện. Những thư viện như fast-json-patch sửa tài liệu tại chỗ khi mutateDocument là true, và thất bại có thể để lại thay đổi một phần.

import { applyPatch, validate } from 'fast-json-patch';

/**
 * Apply a patch immutably and return the original document on failure.
 * @param document The original document, which callers must not mutate
 * @param patch The JSON Patch operation array to apply
 * @returns The result, including a success flag and the new document
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

Ba khuyến nghị triển khai:

  • Sao chép sâu hoặc sao chép có cấu trúc trước, áp dụng trên bản sao, và chỉ hoán đổi tham chiếu sau khi thành công;
  • Đừng trông cậy vào việc thư viện tự quay lui; hành vi khi ngoại lệ khác nhau giữa các triển khai;
  • Kiểm chứng trước khi thực thi: validate chỉ kiểm tra cấu trúc, còn xung đột ngữ nghĩa vẫn cần test; không cái nào thay thế cái nào.

Nếu một patch trải qua nhiều tài nguyên, ví dụ đổi một đơn hàng cùng tồn kho của nó, tính nguyên tử của JSON Patch chỉ đúng trong một tài liệu. Xuyên tài liệu bạn cần một giao dịch cơ sở dữ liệu hoặc hành động bù trừ.

5. Tính bất biến: PATCH không tự nhiên bất biến

Ngữ nghĩa HTTP định nghĩa PUT và DELETE là bất biến, nhưng PATCH thì không nhất thiết. Lặp lại cùng một JSON Patch có thể cho kết quả hoàn toàn khác:

  • add /items/- nối vào cuối mảng mỗi lần, nên thử lại sinh ra bản ghi trùng;
  • replace /version đổi phiên bản 1 thành 2 và thành công lần nữa khi thử lại, dù thao tác đã được áp dụng rồi;
  • remove /temp thất bại ở lần chạy thứ hai vì đường dẫn không còn.

Ba cách phổ biến để đạt tính bất biến:

Cách Nguyên lý Phù hợp nhất
Điều kiện tiên quyết phiên bản Thao tác đầu là test /version, rồi tăng Tài nguyên có phiên bản tường minh
Khoá bất biến Client gửi Idempotency-Key; server cache kết quả Thanh toán, đơn hàng và các lần ghi khác
Patch trạng thái đích Dùng replace thay vì add, nên kết quả chỉ phụ thuộc giá trị cuối Trường cấu hình và trạng thái

Mẫu tệ nhất là patch trộn add và remove mà lại cho rằng thử lại là an toàn. Nếu một patch nối thêm hoặc xoá bất cứ thứ gì, nó phải đi kèm khẳng định phiên bản hoặc khoá bất biến.

6. Xung đột đồng thời: ETag và If-Match

Ngoài test bên trong patch, HTTP cung cấp một cơ chế chuẩn: server trả về ETag, và client gửi If-Match ở request tiếp theo.

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

Nếu tài nguyên đã đổi, server trả 412 Precondition Failed, và client lấy lại trước khi thử lại. If-Match: * nghĩa là "thực thi miễn là tài nguyên tồn tại", không phù hợp khi bạn cần ngăn ghi đè.

Khác biệt giữa test và If-Match:

  • If-Match được kiểm tra ở tầng HTTP trước khi phân tích body, nên rẻ hơn;
  • test được kiểm tra ở tầng ngữ nghĩa patch và có thể khẳng định bất kỳ trường nào mà không phụ thuộc server hỗ trợ ETag;
  • Có thể kết hợp cả hai, nhưng đừng chỉ triển khai một trong hai rồi tuyên bố endpoint an toàn dưới điều kiện đồng thời.

7. Khác biệt thư viện và lựa chọn

Triển khai Ngôn ngữ Tính nguyên tử Hỗ trợ test Ghi chú
fast-json-patch JavaScript Tuỳ chọn (mặc định trả tài liệu mới) Có Hệ sinh thái rộng; cẩn thận mutateDocument
jsonpatch Python Quay lui khi có ngoại lệ Có Sát chuẩn, lỗi rõ ràng
json-patch Go Thao tác trên bản sao Có Thường dùng cùng JSON Merge Patch
zjsonpatch Java Có Có Tích hợp Jackson tiện lợi

Đừng chọn thư viện chỉ dựa trên "hỗ trợ đủ sáu thao tác". Hãy xác nhận ba điều: thất bại có quay lui không, test có so sánh nghiêm ngặt không, và add mảng ngoài phạm vi có báo lỗi không. Các triển khai dễ dãi khiến cùng một patch hành xử khác nhau giữa các môi trường.

8. Danh sách kiểm tra an toàn trước khi ra mắt

  • [ ] Endpoint khai báo application/json-patch+json;
  • [ ] Patch được áp dụng trên một bản sao, và thất bại không bao giờ làm nhiễm bẩn tài liệu gốc;
  • [ ] Mọi path và from trong patch bên ngoài đều qua kiểm tra danh sách trắng;
  • [ ] Những khoá nguy hiểm như __proto__, constructor và prototype bị từ chối;
  • [ ] Số thao tác và kích thước một giá trị được giới hạn trần;
  • [ ] test hoặc If-Match cung cấp kiểm soát đồng thời;
  • [ ] Patch được ghi vào log kiểm toán kèm người thực hiện, thời điểm và toàn bộ mảng thao tác;
  • [ ] Ranh giới giao dịch là tường minh khi một patch trải qua nhiều tài liệu.

Một patch vốn là mô tả đầy đủ "cái gì đã thay đổi", điều này khiến nó thành bản ghi kiểm toán tự nhiên. Về mã lỗi và tính bất biến ở tầng API, hãy đọc tiếp Thiết kế API JSON. Nếu một patch đến từ việc merge hai nhánh, bạn cũng cần ngữ nghĩa xung đột, được trình bày trong Merge Git cho tệp JSON.

FAQ

Thao tác test thất bại nên trả mã trạng thái nào?

Nếu xung đột đến từ một khẳng định phiên bản, cả 409 Conflict lẫn 412 Precondition Failed đều hợp lý. Ưu tiên 412 để nhất quán với If-Match, và đưa phiên bản hiện tại vào body phản hồi để client thử lại.

Patch có add và remove có thể thử lại an toàn không?

Không trực tiếp. add vào cuối mảng nhân bản dữ liệu, và remove thất bại ở lần chạy thứ hai vì đường dẫn đã mất. Hãy thêm điều kiện tiên quyết phiên bản hoặc khoá bất biến để các lần thử lại được nhận diện là cùng một thao tác.

JSON Patch có bảo đảm giao dịch xuyên tài liệu không?

Không. Tính nguyên tử của RFC 6902 áp dụng cho một tài liệu JSON. Khi liên quan đến đơn hàng, tồn kho hay tài nguyên khác, hãy dùng giao dịch cơ sở dữ liệu, bù trừ Saga hoặc hàng đợi tin nhắn để đạt nhất quán.

Vì sao một patch không bao giờ được ghi vào __proto__?

Vì phần lớn triển khai JavaScript lưu tài liệu trong object thông thường, việc ghi __proto__ có thể đổi chuỗi prototype và ảnh hưởng đến mọi object về sau. Danh sách trắng đường dẫn là phòng ngừa đáng tin nhất; đừng chỉ dựa vào việc lọc nội bộ của thư viện.

Làm sao làm một request PATCH bất biến?

Có ba cách phổ biến: bắt đầu patch bằng test /version và tăng phiên bản khi thành công, để server cache kết quả theo Idempotency-Key, hoặc dùng replace thay vì add để kết quả chỉ phụ thuộc giá trị cuối. Nếu một patch nối thêm hoặc xoá bất cứ thứ gì, nó phải đi kèm khẳng định phiên bản hoặc khoá bất biến; không thể chỉ thử lại đơn giản.

Có nên dùng cả test và If-Match không?

Bạn không bắt buộc phải dùng cả hai, nhưng chỉ triển khai một trong hai không làm một endpoint an toàn dưới điều kiện đồng thời. If-Match được kiểm tra ở tầng HTTP trước khi phân tích body, nên rẻ hơn; test được kiểm tra ở tầng ngữ nghĩa patch và có thể khẳng định bất kỳ trường nào mà không phụ thuộc server hỗ trợ ETag.