JSON Pointer, Patch và Merge Patch
Kết luận: Ba đặc tả ngắn tạo thành một giao thức thay đổi: Pointer định vị, Patch sửa chính xác, Merge Patch hợp nhất từng phần. Chọn sai sẽ làm đau mảng và điều kiện đồng thời.

Tóm lại: dùng JSON Pointer để định vị theo vị trí và JSONPath để lọc hoặc đệ quy; giao việc chèn/xoá mảng, ghi null và khẳng định phiên bản cho JSON Patch, và để dành Merge Patch cho cấu hình phẳng và tài nguyên dạng biểu mẫu.
Định vị và sửa là hai việc khác nhau, và các đặc tả chia chúng thành ba tài liệu. Cái giá thường gặp của việc trộn lẫn là "cả mảng bị thay thế" mà không ai nhận ra.
1. JSON Pointer: cú pháp định vị ngắn nhất
RFC 6901 chỉ làm đúng một việc: trỏ đến một nút trong tài liệu bằng một chuỗi.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
Ba quy tắc:
- Chuỗi rỗng trỏ đến chính nút gốc, không phải "nút rỗng";
- Mỗi
/phân tách một token tham chiếu; mảng dùng chỉ số thập phân (không cho phép số 0 đứng đầu, nên01không hợp lệ); - Bên trong một token,
~được viết thành~0và/thành~1— thay~trước/, nếu không bạn sẽ escape kép.
/**
* Escapes a single reference token into a JSON Pointer segment.
* @param token Original key name or index
* @returns Escaped token
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* Joins an array of key names into a JSON Pointer.
* @param tokens Array of key names or indices
* @returns Pointer string beginning with /
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
Giá trị thể hiện rõ nhất khi chính khoá chứa /: pointer cho {"a/b": 1} là /a~1b, không phải /a/b (cái sau nghĩa là b bên trong a).
Khi dùng trong URL, cũng cần mã hoá phần trăm, vì # và ~ có ý nghĩa đặc biệt trong fragment.
2. JSON Patch: một chuỗi sáu thao tác nguyên tử
RFC 6902 mô tả thay đổi bằng một mảng thao tác, media type application/json-patch+json:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新的名字" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| Thao tác | Ngữ nghĩa | Ranh giới |
|---|---|---|
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 (nối thêm); - nghĩa là cuối |
remove |
Xoá | Đường dẫn phải tồn tại |
replace |
Thay một giá trị đã có | Đường dẫn phải tồn tại (tương đương remove + add, nhưng yêu cầu đường dẫn tồn tại trước) |
move |
Di chuyển | 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 | Nguồn phải tồn tại |
test |
Khẳng định bằng nhau | Nếu thất bại, cả patch không được áp dụng; dùng cho khoá lạc quan |
Thuộc tính then chốt là tính nguyên tử: nếu bất kỳ thao tác nào thất bại, cả patch quay lui, nên không có trạng thái trung gian kiểu "ba cái đầu đã áp dụng còn cái thứ tư thất bại".
Điều này khiến nó rất hợp làm body request PATCH.
2.1 Dùng test cho đồng thời lạc quan
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Client GET etag trước rồi ghi nó vào thao tác test. Khi ghi đồng thời, test của request thứ hai thất bại và server trả
409 Conflict hoặc 412 Precondition Failed. Cách này an toàn hơn nhiều so với "ai ghi sau thắng" và không cần khoá phân tán.
2.2 Thao tác mảng dễ sai nhất
// Want to insert at index 1
{ "op": "add", "path": "/items/1", "value": {...} } // Correct: insert; the original index 1 and everything after shift back
// Want to replace index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Correct: replace
add ở chỉ số mảng n là chèn, không phải ghi đè; chỉ số ngoài phạm vi (n > length) phải báo lỗi.
Một số triển khai dễ dãi với điều này, khiến cùng một patch hành xử khác nhau giữa các server.
3. Merge Patch: một mảnh trông giống đích
RFC 7386 chỉ có bốn quy tắc:
- Đích là object và patch là object → hợp nhất đệ quy;
- Giá trị một khoá trong patch là
null→ xoá khoá đó khỏi đích; - Giá trị một khoá trong patch không phải object → thay thế trực tiếp;
- Bản thân patch không phải object → thay thế toàn bộ đích.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
Media type là application/merge-patch+json. Nó đọc như một "cập nhật từng phần", điều này khiến nó đặc biệt dễ bị dùng sai:
- Mảng chỉ có thể bị thay thế toàn bộ. Muốn xoá
list[2]? Merge Patch không làm được; - Không thể ghi
null.nullđã bị chiếm để chỉ việc xoá; - Nó không biểu đạt được trạng thái thứ ba giữa "để trường này yên" và "xoá trường này" (ví dụ đặt nó thành rỗng một cách tường minh).
4. Ma trận năng lực: chọn cái nào
| Yêu cầu | JSON Patch | Merge Patch |
|---|---|---|
| Thay chính xác một phần tử mảng cụ thể | ✓ | ✗ |
| Xoá một mục khỏi mảng | ✓ | ✗ |
| Cập nhật từng phần vài trường | ✓ (nhiều replace) | ✓ (ngắn gọn nhất) |
Ghi một giá trị null |
✓ | ✗ |
| Kiểm soát đồng thời (khẳng định phiên bản) | ✓ (test) | ✗ (chỉ qua If-Match) |
| Độ dễ đọc của patch | Trung bình | Tốt (trông như chính dữ liệu) |
| Độ phức tạp triển khai | Cao | Thấp |
| Hình dạng tài nguyên phù hợp | Lồng sâu, dạng danh sách | Cấu hình phẳng, biểu mẫu |
Quy tắc ngón tay cái:
- Biểu mẫu, trang thiết lập, tài nguyên cấu hình → Merge Patch; frontend chỉ gửi những trường đã đổi;
- Thêm/xoá danh sách, chuyển trạng thái máy trạng thái, bất cứ thứ gì cần bảo vệ đồng thời → JSON Patch;
- Cả hai có thể cùng tồn tại: các endpoint khác nhau khai báo
Content-Typekhác nhau, và server điều phối theo kiểu.
5. Ghi chú triển khai
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// test operations are validated by default; setting mutateDocument to true modifies in place, use with caution
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
Bốn điểm dễ bị bỏ qua:
- Đừng sửa object dùng chung tại chỗ; một patch thất bại để lại trạng thái sửa dở (trừ khi triển khai bảo đảm quay lui);
- Kiểm chứng đường dẫn: patch đến từ bên ngoài phải kiểm chứng mọi
path/fromvà từ chối những khoá như__proto__vàconstructor; - Giới hạn kích thước: đặt trần số mục patch để tránh một patch thực hiện hàng trăm nghìn thao tác;
- Kiểm toán: patch vốn phù hợp với log kiểm toán — chúng là mô tả đầy đủ "cái gì đã thay đổi".
6. Ngữ nghĩa lỗi
| Tình huống | Mã trạng thái đề xuất | Giải thích |
|---|---|---|
| Patch không phải mảng / cấu trúc không hợp lệ | 400 Bad Request |
Định dạng body request sai |
Nút được path tham chiếu không tồn tại |
422 Unprocessable Entity |
Cú pháp hợp lệ nhưng ngữ nghĩa không thực thi được |
Một thao tác test thất bại |
409 hoặc 412 |
Chỉ ra xung đột phiên bản |
| Không có quyền sửa đường dẫn đó | 403 Forbidden |
Đừng che bằng 404 trừ khi bạn chủ ý giấu sự tồn tại của tài nguyên |
Phân biệt 400 với 422 rất quan trọng: cái trước nghĩa là "tôi không hiểu patch của bạn", cái sau nghĩa là "tôi hiểu, nhưng dữ liệu này không cho phép thay đổi đó".
7. Danh sách kiểm tra
- [ ] Endpoint khai báo đúng
Content-Type(json-patch+json/merge-patch+json); - [ ] Việc áp dụng patch là nguyên tử, và thất bại không để lại kết quả một phần;
- [ ] Patch từ bên ngoài được kiểm chứng theo danh sách trắng đường dẫn và giới hạn số mục;
- [ ] Kiểm soát đồng thời dùng
testhoặcIf-Match; - [ ] Patch được ghi vào log kiểm toán;
- [ ] Tình huống mảng chọn tường minh JSON Patch; đừng trông cậy vào Merge Patch.
FAQ
Làm sao quyết định giữa JSON Patch và Merge Patch?
Dùng JSON Patch khi cần thay hoặc xoá một phần tử mảng cụ thể, ghi một giá trị null, hoặc khẳng định phiên bản; dùng Merge Patch cho biểu mẫu và tài nguyên cấu hình chỉ cập nhật vài trường. Merge Patch không xoá được một mục mảng, và không ghi được null, vì null đã có nghĩa là xoá.
Vì sao add ở chỉ số mảng n không phải ghi đè?
Vì add chèn vào mảng: phần tử ở chỉ số n và mọi thứ sau nó dịch về sau. Để thay một phần tử đã có, bạn phải dùng replace, và đường dẫn phải tồn tại trước. Chỉ số ngoài phạm vi (n > length) nên báo lỗi; 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 server.
Có nên sửa object gốc tại chỗ không?
Không. Một patch thất bại để lại trạng thái sửa dở trừ khi triển khai bảo đảm quay lui. Hãy áp dụng patch trên một bản sao sâu hoặc bản sao có cấu trúc, rồi hoán đổi tham chiếu sau khi thành công.