כששני מסמכי JSON נבדלים: diff לפי מיקום, diff לפי id, או נרמול קודם
שורה תחתונה: חמישים רשומות הופכות לחמישים ואחת וה-diff מדווח על חמישים ואחת שינויים. הסבר על false positives של text diff, התאמת מערכים לפי id, נרמול עם jq ופאצ'ים ישימים.

שורה תחתונה: נרמלו קודם, ואז השוו — השתמשו ב-jq -S או בסריאליזציה יציבה כדי למחוק את סדר המפתחות, ההזחה ואיות המספרים, מיינו או אינדקסו מערכים לפי מפתח עסקי כשהסדר חסר משמעות, ורק אז הפכו את ה-diff לפאץ'.
הבדל ב-JSON אינו הבדל בטקסט. השוו את הקבצים כמחרוזות וסידור מפתחות אחד יצבע את כל העמוד; השוו מערכים לפי אינדקס והכנסת רשומה אחת תסמן את חמישים האחרות כשינויים.
פאץ' נשלח לשירות ההזמנות, וכל מה שיש לכם הוא שתי תשובות: חמישים רשומות לפני, חמישים ואחת אחרי, כשהן מכילות רשומה חדשה אחת באמצע. כלי ה-diff מדווח על חמישים ואחת שינויים, קיר של אדום, ואתם עדיין לא מצליחים לומר איזו רשומה באמת זזה.
הכלי לא שבור. "השוואה" אומרת שלושה דברים שונים ב-JSON, ורוב האנשים מגיעים כברירת מחדל לשימוש הפחות שימושי.
שחזור אזעקת השווא: הכנסה אחת, חמישים ואחת שינויים
שלושים שורות של Node 22: בנו חמישים הזמנות ({id, sku, qty}), אחר כך הוסיפו אחת בסוף והכניסו אחת בהתחלה, וספרו הבדלים עם יישור אינדקס-אינדקס.
| פעולה | רשומות | שינויים שמדווחים לפי מיקום |
|---|---|---|
| הוספה אחת בסוף | 50 → 51 | 1 |
| הכנסה אחת בהתחלה | 50 → 51 | 51 |
אותה "רשומה אחת עודפת", חמישים ואחת פעמים הרעש. הסיבה שגרתית: כשמשווים לפי אינדקס, ה-element החדש נוחת במיקום 0 וכל רשומה קיימת זזה משבצת אחת למטה. מיקום 0 מול מיקום 0, מיקום 1 מול מיקום 1 — אין זוג שווה.
בנו Map עם מפתח id במקום, ואותם נתונים מדווחים בדיוק על ערך אחד: $[id=ORD-999] added.
הניסוי הזה נותן לכם את הכלל שחשוב יותר מכל המלצת כלי: האם המערך הוא רצף סדור או קבוצה לא סדורה קובע איזו השוואה צריך להשתמש.
שלוש דרכים שבהן text diff משקר על JSON
diff a.json b.json משווה תווים. JSON נושא מבנה נתונים. אי-ההתאמה הזו מייצרת שלושה סוגים אמינים של false positives.
1. מפתחות אובייקט מסודרים מחדש
סעיף 4 ב-RFC 8259 מפורש: אובייקט הוא "אוסף לא סדור של זוגות שם/ערך". שני המסמכים האלה הם אותו ערך:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
line diff קורא לשלוש השורות שונות. הפכתי את סדר המפתחות של אובייקט עם עשרה מפתחות, עיצבתי את שניהם והשוויתי שורה-שורה: כל עשר השורות סומנו, בעוד השוואה מבנית אומרת שהן שוות. יחס רעש 10:0.
למה סדר המפתחות משתנה בכלל? החלפת סריאליזרים, הרצת מפרמט עם מפתחות ממוינים, בנייה מחדש של האובייקט ממילון או מ-hash map, מעבר בין סביבות ריצה של שפות — כל אחד מאלה מספיק כדי שאותם נתונים ייראו שונים על הדיסק.
2. רווח לבן והזחה
{"a":1} ו-{\n "a": 1\n} נפרסים לאותו ערך. text diff רואה תווי שורה ורווחים ומסמן הכול.
3. איותים שונים של מספרים ו-escapes
1.0 ו-1, 1e2 ו-100, "\u00e9" ו-"é" — אותו ערך אחרי פיענוח, טקסט שונה אחרי סריאליזציה. זה מתון יותר כי הוא נוגע במעט שורות, אבל בסקירות תצורה זה מספיק כדי לגרום לכם לחשוב שמספר השתנה.
המקרה ההפוך הוא מה שטקסט לא יכול לראות ומבנה כן: 5 מול "5". זוג מירכאות נוסף, אבל הטיפוס סחף — בדרך כלל באג ולא עדכון. השוואה מבנית מדווחת עליו כ-modify בלי להסס.
שלושה מודי השוואה, שלושה תפקידים
| מוד | מה מושווה | מאיפה מגיע הרעש | פלט | מתאים ל |
|---|---|---|---|---|
| text diff | שורות תווים | סדר מפתחות, הזחה, איות מספרים | מספרי שורות, אדום/ירוק | חבילות לוקליזציה, fixtures עם סדר מפתחות יציב |
| structural diff | עצים מפוענחים, מיושרים לפי נתיב | מערכים מיושרים לפי אינדקס | נתיבים עם add/remove/modify | תשובות API, תצורות, webhooks |
| semantic diff | עצים ועוד מפתחות עסקיים | רק בחירת מפתח גרועה | נתיבים עם זיהוי move/insert | רשימות רשומות, הזמנות, מלאי |
structural diff פותר את שכבת "סדר מפתחות ופורמט". semantic diff הולך צעד אחד נוסף ופותר את "זהות element במערך". ההבדל אינו מורכבות מימוש; הוא האם אתם מוכנים לומר לכלי איזה שדה הוא הדרכון של רשומה.
מערכים הם קו הגבול
שאלו שאלה אחת: האם הסדר של המערך הזה הוא עצמו נתונים?
- הסדר הוא נתונים: שלבי מיגרציה, דירוגי חיפוש, נקודות בגרף, שרשראות אישור. שינוי מיקום הוא שינוי התנהגות, ולכן השוואה לפי מיקום נכונה.
- הסדר חסר משמעות: תגים, קבוצות מזהי הרשאה, שורות הזמנה, רשימות מלאי. הכנסה לא אמורה להשפיע על איך ששאר הפריטים נשפטים.
כשהסדר חסר משמעות, אינדקסו את הרשומות לפי מפתח במקום להכות אותן לפי מיקום:
// התאמה לפי id: הכנסה או מחיקה מדווחות על ערך אחד, שאר הפריטים נשארים במקום
function diffById(before, after, path = '$') {
const out = [];
const beforeMap = new Map(before.map((item) => [item.id, item]));
const afterMap = new Map(after.map((item) => [item.id, item]));
for (const [id, value] of afterMap) {
if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
}
for (const [id, value] of beforeMap) {
if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
out.push({ path: `${path}[id=${id}]`, type: 'changed' });
}
}
return out;
}
ספריות בוגרות הופכות את זה לאפשרות. jsondiffpatch משתמשת ב-LCS כדי למצוא את הרצף המשותף הארוך ביותר, אבל אובייקטים בתוך מערכים מותאמים לפי מיקום כברירת מחדל — התיעוד הרשמי מדגיש שחייבים לספק פונקציית objectHash כדי להתאים אותם לפי תוכן. משתמשים בפעם הראשונה מסיקים לעתים קרובות שהספרייה טיפשה; במציאות הם מעולם לא העבירו מפתח.
גם בחירת המפתח חשובה. id היא הבחירה הבטוחה. sku עובד רק כשה-SKUs ייחודיים. גיבוב של האובייקט כולו הופך "שדה אחד השתנה" ל"אחד הוסר ועוד אחד נוסף", וזה גרוע מהשוואה לפי מיקום.
הטריק המהיר ביותר בטרמינל: נרמול עם jq
אם אתם לא רוצים ספרייה ורק צריכים להסתכל, נרמלו קודם ו-diff אחר כך. jq -S ממיין מפתחות אובייקט רקורסיבית ומחיל הזחה עקבית:
# נרמול, ואז השוואה שורה-שורה; רעש של סדר מפתחות והזחה נעלם
diff -u <(jq -S . before.json) <(jq -S . after.json)
# כשהמערך באמת לא סדור, מיינו לפי מפתח לפני ההשוואה
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
<(jq -S '.items |= sort_by(.id)' after.json)
הרצתי printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S . וקיבלתי {"a":1,"arr":[1,2],"b":2} — מפתחות ממוינים, arr לא נגוע. זכרו את זה: -S ממיין רק מפתחות אובייקט, לעולם לא elements של מערך. או מיינו מערכים במפורש עם sort / sort_by, או קבלו רעש מיקומי.
שתי מלכודות:
jqלא יכול לערוך במקום; הוא כותב ל-standard output. השתמשו ב-process substitution (<(...)), או כתבו קובץ זמני, אמתו, ואז החליפו;-rמסיר מירכאות ממחרוזות, ולכן הפלט יכול להכיל תווי שורה או תווים בעלי משמעות ל-shell. עטפו נתונים לא מהימנים במירכאות לפני שתעבירו אותם הלאה.
מה /diff של האתר עושה, ומה לא
כלי השוואת ה-JSON המובנה עוקב אחרי אותה גישה מבנית: שני הצדדים מפוענחים לעצים ומיושרים רקורסיבית לפי נתיב, ומייצרים path + type(add/remove/modify) + before/after. סדר מפתחות של אובייקט לא משפיע על התוצאה, והכול רץ בדפדפן — payloads שהודבקו לעולם לא מועלים.
המערכים שלו, לעומת זאת, מיושרים לפי אינדקס. זו פשרה מכוונת, לא פגם:
- הוספה בסוף, שינוי באותו מיקום או החלפה כוללת נותנים פלט נקי;
- הכנסת רשומה בהתחלה או באמצע מסמנת את כל מה שאחריה כ-
modify, ועליכם לזהות ש"למעשה רק רשומה אחת חדשה".
לכן כשמשווים רשימות הזמנות, המהלך הנכון הוא למיין את שני הצדדים לפי id לפני ההדבקה. קלט ממוין מקבע את נקודת ההכנסה ומכווץ את הרעש לערך אחד. אין צורך בהתקנה, וזה מכסה את רוב המקרים היומיומיים.
אם צריך "לייצר פאץ' ישים" או "לזהות elements שזזו", הכלי שבדף לא עושה את זה. זה הסעיף הבא.
הפיכת diff לפאץ': שני פורמטים, אל תערבבו ביניהם
בני אדם קוראים diffs; תוכניות מחילות פאצ'ים. ל-JSON יש שני פורמטי פאץ' עם סמנטיקה שונה לחלוטין.
| ממד | JSON Patch (RFC 6902) | JSON Merge Patch (RFC 7396) |
|---|---|---|
| צורה | מערך פעולות עם op, path, value |
אובייקט JSON בצורת מסמך היעד |
| מערכים | כן, לפי אינדקס, /items/0, /- להוספה בסוף |
לא, מערכים מוחלפים במלואם |
| מחיקת מפתח | remove |
רק דרך null, שאינו יכול למחוק שדה שערכו החוקי הוא null |
| הבעת move | move / copy |
לא |
| מתאים ל | משאבים שמכילים מערכים, לוגי ביקורת, עדכונים אטומיים | עדכונים חלקיים של אובייקטים פשוטים, אספקת תצורה |
ל-JSON Patch יש פעולה אחת שאנשים מדלגים עליה: test.
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
אם test נכשל, כל הפאץ' נדחה. זו קונקורציה אופטימיסטית מובנית בפרוטוקול — אין צורך בכותרת If-Match נוספת כדי להבטיח "כתבתי רק כי ראיתי גרסה 3". עבור endpoint של PATCH זה שווה יותר מכל מוסכמת תיעוד.
נתיבים משתמשים ב-JSON Pointer (RFC 6901): ~0 מבריח ~, ~1 מבריח /. מפתחות שמכילים סלאשים (סוגי MIME כמפתחות, למשל) חייבים escaping, אחרת המצביע נוחת במקום אחר. הפרטים נמצאים בJSON Pointer ו-Patch.
נשארת בעיה מעשית אחת: פאצ'ים שנוצרים אוטומטית מ-diff בדרך כלל מפנים למערכים לפי אינדקס. אינדקסים שבירים בפרודקשן — הכנסה אחת במקביל ו-/items/3 כבר לא מצביע לאותה רשומה. או שהגבלתם פאצ'ים שנוצרים לשדות אובייקט, או שאימתתם את השדות הקריטיים עם test לפני ההחלה.
הכנסת ההשוואה ל-CI: אמתו ערכים, לא טקסט
שלושה כללים מספיקים:
- אמתו ערכים מפוענחים בבדיקות, לעולם לא את הפלט של
JSON.stringify. ב-Jest השתמשו ב-toEqual; ב-Pythonassert a == bאחריjson.loads. סדר מפתחות והזחה לא משתתפים. - הכניסו שדות דינמיים לרשימה לבנה.
requestId,timestamp,traceIdמשתנים בכל ריצה; בלי החרגות הבדיקה נשארת אדומה לתמיד.propertyFilterב-jsondiffpatch ו-exclude_pathsב-deepdiff קיימים בשביל זה. - הדפיסו נתיבים בכשל, לא את המסמך כולו. אף אחד לא קורא 5 MB של payload בלוג;
$.data.items[id=ORD-042].qty: 2 → 3מאתר את הבעיה מיד.
סחיפת תצורה עובדת אותו דבר: הריצו jq -S על תצורות staging ופרודקשן, מיינו מערכים לפי מפתח עסקי, ומה שנשאר הוא הבדל אמיתי. סוג הבעיה הזה — אותם נתונים שנראים שונים בין סביבות — הוא בדרך כלל חוזה לא מוגדר, וזה מה שתכנון חוזה API עוסק בו.
FAQ
האם סדר מפתחות נחשב להבדל?
לא. RFC 8259 מגדיר אובייקטים כאוספים לא סדורים, ולכן {"a":1,"b":2} ו-{"b":2,"a":1} הם אותו ערך. כל כלי שמדווח על סידור מחדש כשינוי עושה השוואת טקסט, לא השוואת JSON.
למה הכנסת רשומה אחת מסמנת כל ערך כשונה?
כי הכלי מיישר מערכים אינדקס אחר אינדקס. ה-element החדש יושב במיקום 0, כל רשומה קיימת זזה אחת למטה, ואין שני מיקומים שמתאימים. מיינו או אינדקסו לפי מפתח כמו id לפני ההשוואה במקום להשוות לפי מיקום.
האם כלי השוואה מקוונים מעלים את הנתונים שלי?
זה תלוי במימוש. כלים שמרונדרים בשרת שולחים את ה-payload ל-backend, שם הוא עשוי להירשם בלוג; כלים בצד הלקוח מפענחים בדפדפן ולא מעלים דבר. לפני השוואת תצורות פרודקשן או payloads של משתמשים, ודאו שהדף מחשב מקומית, או נתקו את הרשת ונסו שוב.
האם אפשר להשתמש בתוצאת diff ישירות כפאץ'?
לא ישירות. diff אומר היכן הדברים נבדלים; JSON Patch אומר אילו פעולות להריץ ובאיזה סדר. שכבת המרה יושבת ביניהם. פאצ'ים שנוצרים נוטים גם הם לפנות למערכים לפי אינדקס, מה שנשבר תחת קונקורציה — הוסיפו פעולות test לפני ההחלה.
איך משווים קבצים של עשרות מגה-בייט?
הזרימו קודם (jq -c כותב רשומה אחת לשורה), או חלצו רק את תת-העץ שמעניין אתכם, כמו jq '.data.items'. השוואה רקורסיבית מלאה של עשרות מגה-בייט עדיין נכנסת לזיכרון; מעל מאה מגה-בייט, פצלו אותה או אינדקסו לפי מפתח. לנתוני זיכרון וזמנים קונקרטיים, ראו ביצועים של JSON גדול.
האם למיין מערכים לפי id לפני ההשוואה?
כן. כלי השוואת ה-JSON המובנה מיישר מערכים לפי אינדקס, ולכן הכנסת רשומה אחת בהתחלה או באמצע מסמנת את כל מה שאחריה כ-modify; מיון שני הצדדים לפי id לפני ההדבקה מקבע את נקודת ההכנסה ומכווץ את הרעש לערך אחד. היוצא מן הכלל הוא כשסדר המערך הוא עצמו נתונים — שלבי מיגרציה, דירוגי חיפוש, נקודות בגרף — ואז השוואה לפי מיקום נכונה והמיון שגוי.