JSON Pointer، Patch و Merge Patch
جمعبندی: سه مشخصات کوتاه یک پروتکل تغییر میسازند: Pointer مکانیابی میکند، Patch دقیق ویرایش میکند، Merge Patch جزئی ادغام میکند. انتخاب اشتباه به آرایهها و همزمانی آسیب میزند.

جمعبندی: از JSON Pointer برای مکانیابی بر اساس موقعیت و JSONPath برای فیلتر یا بازگشت استفاده کنید؛ درج/حذف آرایه، نوشتن null و اعلان نسخه را به JSON Patch بسپارید، و Merge Patch را برای پیکربندی مسطح و منابع فرم نگه دارید.
مکانیابی و تغییر دو چیز متفاوتاند و مشخصات آنها را به سه سند تقسیم کرده. قیمت معمول قاطی کردنشان «کل آرایه جایگزین شد» است و کسی متوجه نمیشود.
۱. JSON Pointer: کوتاهترین نحو مکانیابی
RFC 6901 دقیقاً یک کار میکند: با یک رشته به گرهای در سند اشاره میکند.
"" → the whole document
"/store" → the store member under the root
"/store/book" → one level deeper
"/store/book/0/title" → item 0 of the array
سه قاعده:
- رشته خالی به خود گره ریشه اشاره میکند، نه «گره خالی»؛
- هر
/یک توکن ارجاع را جدا میکند؛ آرایهها از اندیس اعشاری استفاده میکنند (صفر پیشرو مجاز نیست، پس01نامعتبر است)؛ - داخل یک توکن،
~بهصورت~0و/بهصورت~1نوشته میشود —~را قبل از/جایگزین کنید، وگرنه escape مضاعف میگیرید.
/**
* 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('');
}
ارزشش وقتی بهترین است که خود کلید شامل / باشد: pointer برای {"a/b": 1} برابر /a~1b است، نه /a/b (دومی یعنی b زیر a).
وقتی داخل URL استفاده میشود، percent-encoding هم لازم است، چون # و ~ در fragment معنای ویژه دارند.
۲. JSON Patch: دنباله اتمیک شش عملیات
RFC 6902 تغییرات را با آرایهای از عملیات توصیف میکند، نوع رسانه 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" }
]
| عملیات | معناشناسی | مرز |
|---|---|---|
add |
عضو اضافه یا عنصر آرایه درج میکند | اندیس آرایه میتواند برابر طول باشد (ضمیمه)؛ - یعنی انتها |
remove |
حذف میکند | مسیر باید وجود داشته باشد |
replace |
مقدار موجود را جایگزین میکند | مسیر باید وجود داشته باشد (معادل remove + add، اما اول مسیر باید موجود باشد) |
move |
جابهجا میکند | نمیتوان گره را زیر مسیر خودش برد |
copy |
کپی میکند | منبع باید وجود داشته باشد |
test |
برابری را اعلان میکند | اگر شکست بخورد، کل patch اعمال نمیشود؛ برای قفل خوشبینانه |
ویژگی کلیدی اتمیک بودن است: اگر هر عملیاتی شکست بخورد، کل patch برمیگردد، پس حالت میانی «سه مورد اول اعمال شد و چهارمی شکست خورد» وجود ندارد.
این آن را بدنه درخواست PATCH عالی میکند.
۲.۱ استفاده از test برای همزمانی خوشبینانه
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
کلاینت اول etag را GET میکند و در عملیات test مینویسد. روی نوشتن همزمان، test درخواست دوم شکست میخورد و سرور 409 Conflict یا 412 Precondition Failed برمیگرداند. این بسیار امنتر از آخرین-نوشتن-برنده است و قفل توزیعشده نمیخواهد.
۲.۲ عملیات آرایه خطاخیزتریناند
// 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 در اندیس آرایه n یک درج است، نه بازنویسی؛ اندیس بیرون از بازه (n > length) باید خطا بدهد.
برخی پیادهسازیها در این باره سهلگیرند، که همان patch را در سرورهای مختلف متفاوت رفتار میکند.
۳. Merge Patch: قطعهای که شبیه هدف است
RFC 7386 فقط چهار قاعده دارد:
- هدف شیء است و patch شیء است → ادغام بازگشتی؛
- مقدار یک کلید در patch برابر
nullاست → آن کلید از هدف حذف میشود؛ - مقدار یک کلید در patch شیء نیست → مستقیم جایگزین میشود؛
- خود patch شیء نیست → کل هدف جایگزین میشود.
// Target
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Patch
{ "a": "z", "c": { "f": null } }
// Result
{ "a": "z", "c": { "d": "e" } }
نوع رسانه application/merge-patch+json است. شبیه «بهروزرسانی جزئی» خوانده میشود، که سوءاستفاده از آن را مخصوصاً آسان میکند:
- آرایهها فقط یکجا جایگزین میشوند. میخواهید
list[2]را حذف کنید؟ Merge Patch نمیتواند؛ - نمیتوانید
nullبنویسید.nullاز قبل معنای حذف دارد؛ - نمیتواند حالت سومی بین «این فیلد را دست نزن» و «این فیلد را حذف کن» بیان کند (مثلاً صریحاً تنظیم به خالی).
۴. ماتریس توانایی: کدام را انتخاب کنیم
| نیاز | JSON Patch | Merge Patch |
|---|---|---|
| جایگزینی دقیق عنصر خاص آرایه | ✓ | ✗ |
| حذف یک آیتم از آرایه | ✓ | ✗ |
| بهروزرسانی جزئی چند فیلد | ✓ (چند replace) | ✓ (مختصرترین) |
نوشتن مقدار null |
✓ | ✗ |
| کنترل همزمانی (اعلان نسخه) | ✓ (test) | ✗ (فقط از طریق If-Match) |
| خوانایی patch | متوسط | خوب (شبیه خود داده) |
| پیچیدگی پیادهسازی | بالا | پایین |
| شکل منابع مناسب | عمیقاً تودرتو، فهرستمانند | پیکربندی مسطح، فرمها |
قواعد سرانگشتی:
- فرمها، صفحه تنظیمات، منابع پیکربندی → Merge Patch؛ فرانتاند فقط فیلدهای تغییریافته را ارسال میکند؛
- افزودن و حذف فهرست، گذارهای ماشین حالت، هر چیزی که محافظت همزمانی لازم دارد → JSON Patch؛
- هر دو میتوانند همزیستی کنند: endpointهای مختلف
Content-Typeمتفاوت اعلام میکنند و سرور بر اساس نوع توزیع میکند.
۵. نکات پیادهسازی
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);
چهار نکته آسان از دست رفتنی:
- اشیای مشترک را درجا دگرگون نکنید؛ patch شکستخورده حالت نیمهتغییریافته برجا میگذارد (مگر پیادهسازی rollback را تضمین کند)؛
- اعتبارسنجی مسیر: patchهای بیرونی باید هر
path/fromرا اعتبارسنجی و کلیدهایی مانند__proto__وconstructorرا رد کنند؛ - محدودیت اندازه: تعداد ورودیهای patch را محدود کنید تا یک patch صدها هزار عملیات انجام ندهد؛
- حسابرسی: patchها ذاتاً مناسب لاگ حسابرسیاند — توصیف کامل «چه چیزی تغییر کرد».
۶. معناشناسی خطا
| موقعیت | کد وضعیت پیشنهادی | توضیح |
|---|---|---|
| patch آرایه نیست / ساختاراً نامعتبر است | 400 Bad Request |
قالب بدنه درخواست اشتباه است |
گره ارجاعشده توسط path وجود ندارد |
422 Unprocessable Entity |
نحواً معتبر اما معنایی قابل اجرا نیست |
عملیات test شکست خورد |
409 یا 412 |
نشاندهنده تعارض نسخه |
| مجوز تغییر آن مسیر وجود ندارد | 403 Forbidden |
با 404 پنهانش نکنید مگر اینکه عمداً وجود منبع را پنهان کنید |
تمایز 400 از 422 مهم است: اولی یعنی «patch شما را نمیفهمم»، دومی یعنی «میفهمم، اما این داده چنین تغییری را مجاز نمیداند».
۷. چکلیست
- [ ] endpoint
Content-Typeدرست را اعلام میکند (json-patch+json/merge-patch+json)؛ - [ ] اعمال patch اتمیک است و شکست نتیجه جزئی برجا نمیگذارد؛
- [ ] patchهای بیرونی در برابر فهرست سفید مسیر و محدودیت تعداد ورودی اعتبارسنجی میشوند؛
- [ ] کنترل همزمانی از
testیاIf-Matchاستفاده میکند؛ - [ ] patchها در لاگ حسابرسی نوشته میشوند؛
- [ ] سناریوهای آرایه صریحاً JSON Patch را انتخاب میکنند؛ به Merge Patch حساب نکنید.
پرسشهای متداول
چگونه بین JSON Patch و Merge Patch تصمیم بگیرم؟
وقتی باید عنصر خاص آرایه را جایگزین یا حذف کنید، مقدار null بنویسید یا نسخه را اعلان کنید از JSON Patch استفاده کنید؛ برای فرمها و منابع پیکربندی که فقط چند فیلد را بهروزرسانی میکنند از Merge Patch. Merge Patch نمیتواند یک آیتم آرایه را حذف کند و نمیتواند null بنویسد، چون null از قبل معنای حذف دارد.
چرا add در اندیس آرایه n بازنویسی نیست؟
چون add در آرایه درج میکند: عنصر اندیس n و همه بعد از آن عقب میروند. برای جایگزینی عنصر موجود باید replace استفاده کنید و مسیر اول باید وجود داشته باشد. اندیس بیرون از بازه (n > length) باید خطا بدهد؛ پیادهسازیهای سهلگیر همان patch را در سرورهای مختلف متفاوت رفتار میکنند.
آیا باید شیء اصلی را درجا تغییر دهم؟
نه. patch شکستخورده حالت نیمهتغییریافته برجا میگذارد مگر پیادهسازی rollback را تضمین کند. patch را روی کپی عمیق یا structured-clone اعمال کنید، سپس مرجع را پس از موفقیت جابهجا کنید.