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

שורה תחתונה: אמתו קודם את נתיבי היעד וההרשאות של הפאצ', אחר כך הוסיפו הכרזת מקביליות עם 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 }
]
השתמשו ברשימת היתר ולא ברשימת חסימה:
- הרשו לפאצ'ים לשנות רק שדות עסקיים, כמו
/statusאו/items/0/quantity; - אמתו כל
pathו-fromמול ביטוי רגולרי או קידומת נתיב, ודחו רמות לא צפויות שאינן אינדקסים במערך; - פרסרו JSON למכלי
Object.create(null)אוMapכדי להפחית את ההשפעה של שרשרת prototype מזוהמת; - הגבילו את מספר הפעולות ואת גודל הבייטים של כל
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 בצד השרת.