JSON Toolkit

JSON Patch בפרודקשן: אטומיות, אידמפוטנטיות וקונפליקטים מקביליים

שורה תחתונה: JSON Patch הוא רק פורמט שינוי; אטומיות, אידמפוטנטיות ומקביליות עדיין באחריותכם. המאמר מכסה תנאי מוקדם עם test, escaping בנתיבים, rollback ותיאום ETag.

מתקדםכ-10 דקות קריאהפורסם 2026-10-31עודכן 2026-10-31
עטיפה עבור JSON Patch בפרודקשן

שורה תחתונה: אמתו קודם את נתיבי היעד וההרשאות של הפאצ', אחר כך הוסיפו הכרזת מקביליות עם test או If-Match; החילו את הפאצ' על עותק כך שכשל לא משאיר שום דבר כתוב חלקית.

הסכנה האמיתית של בקשת PATCH היא לא לכתוב את השדה הלא נכון. היא להאמין שהכתיבה הצליחה בזמן שהיא דרסה בשקט שינוי של מישהו אחר. JSON Patch נותן לכם את הכלים, אבל עיצוב הבטיחות עדיין שלכם.

1. סקירה קצרה של סט הפעולות ב-RFC 6902

JSON Patch מתאר שינויים כמערך פעולות, עם סוג המדיה application/json-patch+json. שש פעולות מבוצעות לפי סדר המערך:

פעולה השפעה אילוץ מרכזי
add הוספת איבר או הכנסת איבר למערך אינדקס במערך יכול להיות שווה לאורך; - פירושו הסוף
remove הסרת איבר נתיב היעד חייב להתקיים
replace החלפת ערך קיים נתיב היעד חייב להתקיים
move העברת צומת אי אפשר להעביר צומת לתוך נתיב הצאצא של עצמו
copy העתקת צומת נתיב המקור חייב להתקיים
test בדיקת שוויון ערכים אם היא נכשלת, כל הפאצ' לא מיושם

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

2. שימוש ב-test כדי להפוך פאצ' לעדכון מותנה

פאצ' טיפוסי של נעילה אופטימיסטית מתחיל לא בכתיבה, אלא בהכרזת גרסה:

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

שמים אותו בפאצ' שלם:

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

ההיגיון הוא: לאשר שגרסת המסמך היא כרגע 1, ואז לשנות אותה ל-2. אם בקשה אחרת כבר קידמה את הגרסה ל-2, ה-test הראשון נכשל, כל הפאצ' לא מיושם, והשרת מחזיר קונפליקט. זה מונע אובדן עדכונים בלי להציג נעילה מבוזרת.

כמה נקודות עיצוב:

  • שימו test לפני כל כתיבה. אם כתיבה מוקדמת כבר נכנסה לתוקף, ההכרזה חסרת משמעות;
  • הכריזו על מספר גרסה או digest שהשרת מייצר, ולא על שדה רגיל שהלקוח יכול לשנות בחופשיות;
  • קדמו את הגרסה בכל כתיבה מוצלחת; אחרת שתי בקשות שמשתמשות באותה גרסה ישנה יעברו שתיהן;
  • ל-value של test חייב להיות בדיוק אותו סוג JSON כמו לערך במסמך. 1 ו-"1" אינם שווים.

3. Escaping בנתיבים וזיהום prototype

כללי ה-escaping של JSON Pointer פשוטים: כותבים ~ כ-~0 ו-/ כ-~1, ומחליפים את ~ לפני /, אחרת מתקבל escaping כפול.

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

הסיכון בפאצ'ים חיצוניים הוא בדרך כלל לא ה-escaping אלא מפתחות מסוכנים. ב-JavaScript, מפתחות כמו __proto__, constructor ו-prototype יכולים לעורר זיהום prototype, אפילו דרך add רגיל:

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

השתמשו ברשימת היתר ולא ברשימת חסימה:

  1. הרשו לפאצ'ים לשנות רק שדות עסקיים, כמו /status או /items/0/quantity;
  2. אמתו כל path ו-from מול ביטוי רגולרי או קידומת נתיב, ודחו רמות לא צפויות שאינן אינדקסים במערך;
  3. פרסרו JSON למכלי Object.create(null) או Map כדי להפחית את ההשפעה של שרשרת prototype מזוהמת;
  4. הגבילו את מספר הפעולות ואת גודל הבייטים של כל value.

4. החלה אטומית ו-rollback

RFC 6902 דורש ביצוע אטומי, אבל אטומיות היא דרישה סמנטית, לא ברירת מחדל בכל ספרייה. ספריות כמו fast-json-patch משנות את המסמך במקום כשהאפשרות mutateDocument דלוקה, וכשל עשוי להשאיר שינויים חלקיים.

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 };
  }
}

שלוש המלצות מימוש:

  • העתיקו עמוק או עשו structured-clone קודם, החילו על העותק, והחליפו את ההפניה רק אחרי הצלחה;
  • אל תסתמכו על rollback אוטומטי של הספרייה; ההתנהגות במקרי חריגה שונה בין מימושים;
  • אמתו לפני ביצוע: validate בודק רק מבנה, בעוד קונפליקטים סמנטיים עדיין דורשים test; אחד לא מחליף את השני.

אם פאצ' חוצה כמה משאבים, למשל שינוי הזמנה והמלאי שלה יחד, האטומיות של JSON Patch מתקיימת רק בתוך מסמך יחיד. בין מסמכים צריך טרנזקציית מסד נתונים או פעולות פיצוי.

5. אידמפוטנטיות: PATCH אינו אידמפוטנטי מטבעו

סמנטיקת HTTP מגדירה את PUT ו-DELETE כאידמפוטנטיים, אבל PATCH אינו בהכרח כזה. חזרה על אותו JSON Patch יכולה להפיק תוצאות שונות לחלוטין:

  • add /items/- מוסיף לסוף המערך בכל פעם, כך שניסיונות חוזרים יוצרים כפילויות;
  • replace /version משנה גרסה 1 ל-2 ומצליח שוב בניסיון חוזר, למרות שהפעולה כבר יושמה;
  • remove /temp נכשל בהרצה השנייה כי הנתיב כבר לא קיים.

יש שלוש דרכים נפוצות להשיג אידמפוטנטיות:

גישה איך זה עובד הכי מתאים ל
תנאי מקדים של גרסה הפעולה הראשונה היא test /version, ואז קידום משאבים עם גרסה מפורשת
מפתח אידמפוטנטיות הלקוח שולח Idempotency-Key; השרת שומר את התוצאה במטמון תשלומים, הזמנות וכתיבות אחרות
פאצ' למצב יעד להשתמש ב-replace במקום ב-add, כך שהתוצאה תלויה רק בערך הסופי שדות תצורה וסטטוס

התבנית הגרועה ביותר היא פאצ' שמערבב add ו-remove תוך הנחה שניסיונות חוזרים בטוחים. אם פאצ' מוסיף או מוחק משהו, הוא חייב להיות מותאם להכרזת גרסה או למפתח אידמפוטנטיות.

6. קונפליקטים מקביליים: ETag ו-If-Match

מלבד test בתוך הפאצ', HTTP מציע מנגנון תקני: השרת מחזיר ETag, והלקוח שולח If-Match בבקשה הבאה.

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"}]

אם המשאב השתנה, השרת מחזיר 412 Precondition Failed, והלקוח שולף מחדש לפני ניסיון חוזר. If-Match: * פירושו "בצע כל עוד המשאב קיים", וזה לא מתאים כשצריך למנוע דריסה.

ההבדל בין test ל-If-Match:

  • If-Match נבדק בשכבת ה-HTTP לפני פרסור הגוף, ולכן זול יותר;
  • test נבדק בשכבת הסמנטיקה של הפאצ' ויכול להכריז על כל שדה בלי להסתמך על תמיכת ETag בצד השרת;
  • אפשר לשלב ביניהם, אבל אל תממשו רק אחד מהם ותצהירו שה-endpoint בטוח תחת מקביליות.

7. הבדלי ספריות ובחירה

מימוש שפה אטומיות תמיכה ב-test הערות
fast-json-patch JavaScript אופציונלית (מחזיר מסמך חדש כברירת מחדל) כן אקוסיסטם רחב; שימו לב ל-mutateDocument
jsonpatch Python חוזר לאחור בחריגות כן קרוב לתקן, עם הודעות שגיאה ברורות
json-patch Go פועל על עותק כן משמש לעיתים קרובות יחד עם JSON Merge Patch
zjsonpatch Java כן כן אינטגרציה נוחה עם Jackson

אל תבחרו ספרייה רק לפי "תומכת בכל שש הפעולות". אשרו שלושה דברים: האם כשל מבטל לאחור, האם test משווה באופן קפדני, והאם add עם אינדקס מחוץ לטווח מעלה שגיאה. מימושים רופפים גורמים לאותו פאצ' להתנהג אחרת בסביבות שונות.

8. רשימת בדיקה לבטיחות לפני עלייה לאוויר

  • [ ] ה-endpoint מצהיר על application/json-patch+json;
  • [ ] פאצ'ים מוחלים על עותק, וכשל לעולם לא מזהם את המסמך המקורי;
  • [ ] כל path ו-from בפאצ'ים חיצוניים עוברים בדיקת רשימת היתר;
  • [ ] מפתחות מסוכנים כמו __proto__, constructor ו-prototype נדחים;
  • [ ] מספר הפעולות וגודל הערך הבודד מוגבלים;
  • [ ] test או If-Match מספקים בקרת מקביליות;
  • [ ] פאצ'ים נכתבים ללוג ביקורת עם מבצע, חותמת זמן ומערך הפעולות המלא;
  • [ ] גבולות טרנזקציה מפורשים כשפאצ' חוצה מסמכים.

פאצ' הוא מטבעו תיאור שלם של "מה השתנה", מה שהופך אותו לרשומת ביקורת טבעית. לקודי שגיאה ואידמפוטנטיות בשכבת ה-API, המשיכו עם עיצוב API ל-JSON. אם פאצ' נובע ממיזוג שני ענפים, צריך גם סמנטיקת קונפליקטים, שמכוסה ב-מיזוג Git לקבצי JSON.

שאלות נפוצות

איזה קוד סטטוס אמור להחזיר כשל של פעולת test?

אם הקונפליקט נובע מהכרזת גרסה, גם 409 Conflict וגם 412 Precondition Failed הגיוניים. העדיפו 412 כדי להישאר עקבי עם If-Match, וכללו את הגרסה הנוכחית בגוף התגובה כדי שהלקוח יוכל לנסות שוב.

אפשר לנסות שוב בבטחה פאצ' שמכיל add ו-remove?

לא ישירות. add לסוף מערך משכפל נתונים, ו-remove נכשל בהרצה השנייה כי הנתיב נעלם. הוסיפו תנאי מקדים של גרסה או מפתח אידמפוטנטיות כדי שניסיונות חוזרים יזוהו כאותה פעולה.

JSON Patch יכול להבטיח טרנזקציות בין מסמכים?

לא. האטומיות של RFC 6902 חלה על מסמך JSON יחיד. כשמעורבים הזמנות, מלאי או משאבים אחרים, השתמשו בטרנזקציות מסד נתונים, בפיצוי Saga או בתור הודעות לעקביות.

למה פאצ' אסור לכתוב ל-__proto__?

כי רוב מימושי ה-JavaScript שומרים את המסמך באובייקטים רגילים, וכתיבה ל-__proto__ יכולה לשנות את שרשרת ה-prototype ולהשפיע על כל אובייקט אחר כך. רשימת היתר לנתיבים היא ההגנה האמינה ביותר; אל תסתמכו רק על סינון פנימי של ספרייה.

איך הופכים בקשת PATCH לאידמפוטנטית?

יש שלוש גישות נפוצות: פתחו את הפאצ' עם test /version וקדמו את הגרסה בהצלחה, תנו לשרת לשמור את התוצאה במטמון עבור Idempotency-Key, או השתמשו ב-replace במקום ב-add כך שהתוצאה תלויה רק בערך הסופי. אם פאצ' מוסיף או מוחק משהו, הוא חייב להיות מותאם להכרזת גרסה או למפתח אידמפוטנטיות; אי אפשר פשוט לנסות אותו שוב.

כדאי להשתמש גם ב-test וגם ב-If-Match?

לא חייבים להשתמש בשניהם, אבל מימוש של רק אחד מהם לא הופך endpoint לבטוח תחת מקביליות. If-Match נבדק בשכבת ה-HTTP לפני פרסור הגוף, ולכן זול יותר; test נבדק בשכבת הסמנטיקה של הפאצ' ויכול להכריז על כל שדה בלי להסתמך על תמיכת ETag בצד השרת.