JSON Toolkit

JSON Pointer, Patch ו-Merge Patch

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

בינוניכ-12 דקות קריאהפורסם 2026-10-30עודכן 2026-10-30
עטיפה עבור JSON 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

שלושה כללים:

  1. המחרוזת הריקה מצביעה על צומת השורש עצמו, ולא על "צומת ריק";
  2. כל / מפריד טוקן הפניה אחד; מערכים משתמשים באינדקסים עשרוניים (אסורים אפסים מובילים, כך ש-01 לא חוקי);
  3. בתוך טוקן, ~ נכתב כ-~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 יש רק ארבעה כללים:

  1. היעד הוא אובייקט והפאצ' הוא אובייקט → מיזוג רקורסיבי;
  2. ערך של מפתח בפאצ' הוא null → מחיקת אותו מפתח מהיעד;
  3. ערך של מפתח בפאצ' אינו אובייקט → החלפה ישירה;
  4. הפאצ' עצמו אינו אובייקט → כל היעד מוחלף.
// 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);

ארבע נקודות קלות לפספוס:

  1. אל תשנו אובייקטים משותפים במקום; פאצ' שנכשל משאיר מצב חצי-משונה (אלא אם המימוש מבטיח rollback);
  2. אימות נתיבים: פאצ'ים מבחוץ חייבים לאמת כל path/from ולדחות מפתחות כמו __proto__ ו-constructor;
  3. מגבלות גודל: הגבילו את מספר הרשומות בפאצ' כדי למנוע מפאצ' יחיד לבצע מאות אלפי פעולות;
  4. ביקורת: פאצ'ים מתאימים מטבעם ללוגי ביקורת — הם תיאור שלם של "מה השתנה".

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, ואז החליפו את ההפניה אחרי הצלחה.