JSON Pointer, Patch ו-Merge Patch
שורה תחתונה: שלושה מפרטים קצרים יוצרים פרוטוקול שינוי: Pointer מאתר, Patch עורך בדיוק, Merge Patch ממזג חלקית. בחירה שגויה כואבת במערכים ובמקביליות.

שורה תחתונה: השתמשו ב-JSON Pointer כדי לאתר לפי מיקום וב-JSONPath כדי לסנן או לחזור רקורסיבית; הפקידו הכנסה והסרה של מערכים, כתיבת null והכרזות גרסה בידי JSON Patch, ושמרו את Merge Patch לקונפיגורציה שטוחה ולמשאבי טפסים.
איתור ושינוי הם שני דברים שונים, והמפרטים מפצלים אותם לשלושה מסמכים. המחיר הרגיל של בלבול ביניהם הוא "כל המערך הוחלף" ואף אחד לא שם לב.
1. 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— מחליפים את~לפני/, אחרת מגיעים ל-escaping כפול.
/**
* 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.
2. 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.
2.1 שימוש ב-test למקביליות אופטימיסטית
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
הלקוח קודם עושה GET ומקבל את ה-etag וכותב אותו בפעולת ה-test. בכתיבות מקביליות, ה-test של הבקשה השנייה נכשל והשרת מחזיר
409 Conflict או 412 Precondition Failed. זה הרבה יותר בטוח מ"הכותב האחרון מנצח" ולא דורש נעילה מבוזרת.
2.2 פעולות מערך הן מועדות השגיאה ביותר
// 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) חייב להעלות שגיאה.
חלק מהמימושים מקלים בכך, מה שגורם לאותו פאצ' להתנהג אחרת בשרתים שונים.
3. Merge Patch: קטע שנראה כמו היעד
ל-RFC 7386 יש רק ארבעה כללים:
- היעד הוא אובייקט והפאצ' הוא אובייקט → מיזוג רקורסיבי;
- ערך של מפתח בפאצ' הוא
null→ מחיקת אותו מפתח מהיעד; - ערך של מפתח בפאצ' אינו אובייקט → החלפה ישירה;
- הפאצ' עצמו אינו אובייקט → כל היעד מוחלף.
// 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כבר תפוס למשמעות של מחיקה; - הוא לא יכול לבטא מצב שלישי בין "השאר את השדה הזה" ל"מחק את השדה הזה" (למשל, הגדרה מפורשת לערך ריק).
4. מטריצת יכולות: מה לבחור
| דרישה | JSON Patch | Merge Patch |
|---|---|---|
| החלפה מדויקת של איבר מסוים במערך | ✓ | ✗ |
| מחיקת פריט אחד ממערך | ✓ | ✗ |
| עדכון חלקי של כמה שדות | ✓ (כמה replace) | ✓ (התמציתי ביותר) |
כתיבת ערך null |
✓ | ✗ |
| בקרת מקביליות (הכרזת גרסה) | ✓ (test) | ✗ (רק דרך If-Match) |
| קריאות הפאצ' | בינונית | טובה (נראה כמו הנתונים עצמם) |
| מורכבות מימוש | גבוהה | נמוכה |
| צורות משאב מתאימות | מקונן עמוק, דומה לרשימה | קונפיגורציה שטוחה, טפסים |
כללי אצבע:
- טפסים, עמודי הגדרות, משאבי קונפיגורציה → Merge Patch; הפרונט שולח רק את השדות שהשתנו;
- הוספה והסרה של פריטים ברשימה, מעברי מכונת מצבים, כל דבר שצריך הגנה על מקביליות → JSON Patch;
- שניהם יכולים לחיות יחד: endpoints שונים מצהירים על
Content-Typeשונים, והשרת מנתב לפי הסוג.
5. הערות מימוש
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);
ארבע נקודות קלות לפספוס:
- אל תשנו אובייקטים משותפים במקום; פאצ' שנכשל משאיר מצב חצי-משונה (אלא אם המימוש מבטיח rollback);
- אימות נתיבים: פאצ'ים מבחוץ חייבים לאמת כל
path/fromולדחות מפתחות כמו__proto__ו-constructor; - מגבלות גודל: הגבילו את מספר הרשומות בפאצ' כדי למנוע מפאצ' יחיד לבצע מאות אלפי פעולות;
- ביקורת: פאצ'ים מתאימים מטבעם ללוגי ביקורת — הם תיאור שלם של "מה השתנה".
6. סמנטיקת שגיאות
| מצב | קוד סטטוס מוצע | הסבר |
|---|---|---|
| הפאצ' אינו מערך / אינו תקין מבחינה מבנית | 400 Bad Request |
פורמט גוף הבקשה שגוי |
הצומת שה-path מפנה אליו לא קיים |
422 Unprocessable Entity |
תקין תחבירית אבל לא ניתן לביצוע סמנטית |
פעולת test נכשלה |
409 או 412 |
מעיד על קונפליקט גרסה |
| אין הרשאה לשנות את הנתיב הזה | 403 Forbidden |
אל תמסכו זאת ב-404 אלא אם כן אתם מסתירים במכוון את קיום המשאב |
ההבחנה בין 400 ל-422 חשובה: הראשון פירושו "אני לא מבין את הפאצ' שלך", השני פירושו "אני מבין אותו, אבל הנתונים האלה לא מתירים את השינוי הזה".
7. רשימת בדיקה
- [ ] ה-endpoint מצהיר על ה-
Content-Typeהנכון (json-patch+json/merge-patch+json); - [ ] החלת הפאצ' אטומית, וכשל לא משאיר תוצאה חלקית;
- [ ] פאצ'ים חיצוניים מאומתים מול רשימת היתר של נתיבים ומגבלת מספר רשומות;
- [ ] בקרת המקביליות משתמשת ב-
testאו ב-If-Match; - [ ] פאצ'ים נכתבים ללוג הביקורת;
- [ ] תרחישי מערכים בוחרים במפורש JSON Patch; לא מסתמכים על Merge Patch.
שאלות נפוצות
איך מחליטים בין JSON Patch ל-Merge Patch?
השתמשו ב-JSON Patch כשצריך להחליף או להסיר איבר מסוים במערך, לכתוב ערך null או להכריז על גרסה; השתמשו ב-Merge Patch לטפסים ולמשאבי קונפיגורציה שמעדכנים רק כמה שדות. Merge Patch לא יכול למחוק פריט בודד במערך, ולא יכול לכתוב null, כי null כבר פירושו מחיקה.
למה add באינדקס n במערך אינו דריסה?
כי add מכניס למערך: האיבר באינדקס n וכל מה שאחריו זזים אחורה. כדי להחליף איבר קיים חייבים להשתמש ב-replace, והנתיב חייב להתקיים קודם. אינדקס מחוץ לטווח (n > length) אמור להעלות שגיאה; מימושים מקלים גורמים לאותו פאצ' להתנהג אחרת בשרתים שונים.
כדאי לשנות את האובייקט המקורי במקום?
לא. פאצ' שנכשל משאיר מצב חצי-משונה אלא אם המימוש מבטיח rollback. החילו את הפאצ' על עותק שהועתק עמוק או עבר structured-clone, ואז החליפו את ההפניה אחרי הצלחה.