JSON למחלקת Java: המחולל נותן לך שלד, לייצור חסרות עוד חמש דברים
שורה תחתונה: מבט מעשי על מה שמחולל JSON-to-Java באמת מפיק — מספרים הופכים ל-Double, אובייקטים מקוננים הופכים למחלקות פנימיות סטטיות, ולא נוצרת אפילו הערת Jackson אחת. דיוק כספי, שינוי שמות שדות, מחיקת טיפוסים, שדות לא מוכרים ותאריכים הם חמשת הדברים שעליך להוסיף בעצמך, עם קוד מוכן להעתקה לכל אחד.

בקיצור: הפלט של מחולל מתקמפל מיד אך אינו מוכן לייצור. ברירות המחדל הנמדדות של המחולל שלנו ממפות כל מספר JSON ל-Double, הופכות אובייקטים מקוננים למחלקות פנימיות סטטיות, ולא מפיקות ולו הערת Jackson אחת. דיוק כספי, שינוי שמות שדות, מחיקת טיפוסים, שדות לא מוכרים ותאריכים — חמש דברים שהוא לעולם לא יעשה בשבילך.
להפוך תגובת API למחלקות Java היא עבודה מכנית: לקרוא את ה-JSON, להעתיק את השדות, לעטוף אותם ב-getters ו-setters. המחיר של עבודה מכנית הוא אותו סכין בלתי נראית — שם תכונה שהועתק שגוי באות אחת לא יגרום שגיאת קימפול. השדה מגיע בשקט כ-null, ואתה מגלה את זה בלוג ייצור. ייצור מבנה מחלקות קיים כדי ליישר שמות וטיפוסים מהיום הראשון. אבל מחולל יכול ליישר רק את מה שגלוי בדגימה.
1. הרץ קודם את המחולל: מה הוא באמת נותן
הדבק תגובה מייצגת ל-כלי JSON to Java שלנו:
{
"order_id": 202611040001,
"buyer": {
"name": "王五",
"vip": true
},
"items": [
{ "sku": "A-1", "price": 19.99, "qty": 2 }
],
"created_at": "2026-11-04T09:30:00+08:00"
}
השלד המיוצר (אפשרויות ברירת מחדל, useLombok כבוי):
public class Root {
private Double orderId;
private Buyer buyer;
private List<Item> items;
private String createdAt;
public Double getOrderId() { return orderId; }
public void setOrderId(Double orderId) { this.orderId = orderId; }
// ...שאר ה-getters/setters עוקבים אחר אותה תבנית
}
Buyer ו-Item הן מחלקות פנימיות סטטיות נפרדות. כללי המיפוי, אחד אחרי השני:
| צורה ב-JSON | טיפוס Java מיוצר | הערות |
|---|---|---|
"name": "王五" |
String |
מחרוזות תמיד הופכות ל-String |
"qty": 2 |
Double |
כל מספר הופך ל-Double, ראה סעיף 2 |
"vip": true |
Boolean |
טיפוס עטוף, null מותר |
"buyer": {...} |
Buyer (מחלקה פנימית סטטית) |
אובייקטים מקוננים הופכים למחלקה משלהם |
"items": [...] |
List<Item> |
טיפוס האיבר נלקח מהאיבר הראשון |
order_id |
orderId |
snake_case מומר ל-camelCase |
שמות מחלקות מקבלים PascalCase (Root), שדות מקבלים camelCase (orderId), והתנגשויות עם מילות מפתח של Java (class, new, int) מקבלות אוטומטית סיומת. קיימות שלוש אפשרויות: packageName, className ו-useLombok, שמחליפה בין @Data ל-getters/setters כתובים ידנית. עם Lombok פעיל, כל הבלוק של קוד החוזר מתכווץ לכמה הערות.
שני גבולות של השלד הזה שכדאי להכיר מראש. ראשית, הוא לא מייצר שום הערת Jackson/Gson — לא @JsonProperty, לא @JsonIgnoreProperties, לא @JsonFormat, כי ההערות קובעות איך deserialization יתבצע, ומטען דגימה לא יכול לחשוף את מדיניות הביצוע שלך. שנית, אין המרה הפוכה: גזירת JSON ממחלקת Java מחוץ להיקף, אותו עיצוב חד-כיווני כמו המסלול של JSON to TypeScript.
2. התיקון הראשון: Double אינו טיפוס לכסף
המחולל ממפה "price": 19.99 ל-Double. זה מתקמפל, זה מוקצה, ובכל זאת זה הטיפוס הלא נכון. נקודה צפה בינארית לא יכולה לייצג 0.1, מה שאומר שהיא לא יכולה לייצג את רוב השברים העשרוניים:
System.out.println(19.99); // 19.99 — נראה תקין
double a = 0.1 + 0.2;
System.out.println(a); // 0.30000000000000004
System.out.println(a == 0.3); // false
הדפסת 19.99 לבדה נראית נקייה כי Java מיישמת ייפוי לייצוג הקצר ביותר; השגיאה מתגלה ברגע שהערך נכנס לחישוב. כל שלושת הכתבות ההנדסיות בנושא מסכימות: מחירים, סכומים ושיעורים צריכים להיות BigDecimal, שנבנה ממחרוזת — new BigDecimal("19.99"), ולא new BigDecimal(19.99), שמביא פנימה את השגיאה של ה-double ישירות.
התיקון המכני הוא מעבר אחרי הייצור: החלף Double ב-BigDecimal רק בשדות כספיים. מלכודת קשורה על אותו שורש: מספרים שלמים מעל 2^53 (מספרי הזמנות, מזהי snowflake) שמועברים דרך Double מאבדים את ספרותיהם האחרונות בשקט — מספר בן עשרים ספרות שמאבד את שלוש הספרות האחרונות הוא תוצאה נמדדת וניתנת לשחזור, והמנגנון זהה לזה שמאחורי למה JSON מאבד דיוק במספרים שלמים גדולים. מפה את השדות האלה ל-String או Long ותן לספרות לנסוע כטקסט.
| המשמעות האמיתית של השדה | המחולל נותן | להחליף ל |
|---|---|---|
| כסף, שיעורים | Double |
BigDecimal (בנאי ממחרוזת) |
| מספרי הזמנות, מזהים (עלולים לעבור 2^53) | Double |
String או Long |
| כמויות, מספרי עמודים | Double |
Integer / Long |
| מספרי צף מדעיים באמת | Double |
השאר |
3. התיקון השני: שמות שאינם תואמים, מקובעים עם @JsonProperty
המרת order_id ל-orderId היא ניחוש רצון טוב: המחולל מניח שאתה רוצה camelCase בצד Java. אבל Jackson מתאים כברירת מחדל מפתחות JSON מול שמות שדות באופן מילולי. כשהם לא תואמים, שום דבר לא נכשל — orderId פשוט נשאר null. שני פתרונות:
ברמת השדה — הוסף @JsonProperty, הערה אחת מקבעת מיפוי אחד:
public class Root {
@JsonProperty("order_id")
private Long orderId;
}
גלובלי — החלף את אסטרטגיית השמות של ה-ObjectMapper ולא נדרשת הערה לשדה בשום מקום בפרויקט:
ObjectMapper mapper = new ObjectMapper();
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
הבחירה לפי השטח: API קבוע אחד — רמת שדה; צוות שלם מדבר עם שירותים ב-snake_case — אסטרטגיה גלובלית. עובדה שלילית אחת ראויה לפסקה משלה כאן — שם שנכתב שגוי נכשל כ-null שקט, לא כחריגה. עמוד הכלי של jsonformater רואה בזה בדיוק את הסיבה לקיומם של מחוללים: העתקה מכנית היא בדיוק המקום שבו שיעור השגיאה הזה מגיע לשיא. אז אחרי הוספת ההערות, כתוב לפחות מבחן assertion אחד שהשדות המפתח אינם null.
4. התיקון השלישי: מחיקת טיפוסים — לעולם אל תבצע deserialization ל-List עירום
המחולל מוסר לך את הצהרת השדה List<Item>, אבל מה שבאמת ממלא את הרשיסטה הזאת הוא Jackson. deserialization ברמה העליונה כך נופל ישר למלכודת מחיקת הטיפוסים:
// שגוי: האיברים הם LinkedHashMap בזמן ריצה
List<Item> items = mapper.readValue(json, List.class);
// נכון: TypeReference שומר על הפרמטר הגנרי
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) מתקמפל ורץ, אבל גנריקות ב-Java קיימות רק בזמן קימפול. בזמן ריצה Jackson רואה רק List, ולכן האיברים נוחתים כ-LinkedHashMap. השגיאה לא נורה מיד — היא נורה ב-item.getSku() הראשון אי-שם, מזנקת ClassCastException רחוק מהמקום שבו השגיאה נעשתה. שלוש צורות נכונות, לפי תרחיש:
// 1. הרמה העליונה היא המערך
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// 2. שדה של אובייקט ברמה העליונה
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// 3. בניית JavaType מפורשת
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
בצד של Gson המקבילה היא TypeToken. כל שלושת המדריכים מציינים זאת כסעיף נפרד, כי היא נכשלת מאוחר ומדווחת רחוק.
5. התיקון הרביעי: שדות לא מוכרים — אל תיתן להתפתחות ה-API לשבור deserialization
API שמוסיף שדה הוא ההתפתחות הרגילה ביותר. אבל ברירת המחדל של Jackson היא לזרוק UnrecognizedPropertyException על שדות לא מוכרים — הספק מוסיף שדה והשירות שלך מתחיל להיכשל. כבה את זה בשתי רמות:
// רמת מחלקה: סבלנות ללא מוכרים רק במחלקה הזאת
@JsonIgnoreProperties(ignoreUnknown = true)
public class Root { ... }
// גלובלי: ה-mapper כולו סבלן
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
אחרי כיבוי, מופיעה בעיה מראתית: סבלנות משתיקה גם שמות שדות שנכתבו בשגיאה, ופותחת צ'ק חופשי למצב הכשל "null שקט" מהסעיף הקודם. דרך הביניים: לרוץ בייצור עם סבלנות כבויה ובסוויטת הבדיקות עם מופעלת, כדי ששגיאות כתיב יתגלו בזמן בדיקות. Gson מתעלם משדות לא מוכרים כברירת מחדל — לכן הוא מרגיש קל באינטגרציות מהירות, והמחיר של הנוחות הזאת הוא שאף פעם לא תדע מה הספק הוסיף.
6. התיקון החמישי: תאריכים ובנאי ללא פרמטרים
המחולל ממפה created_at ל-String, שזהו למעשה ברירת המחדל הבטוחה ביותר: ב-JSON תאריך הוא רק מחרוזת, והפורמט על החוט יכול להיות ISO-8601, טיימסטאמפ של יוניקס או משהו מותאם — הדגימה לא יכולה לומר לך איזה אתה רוצה. כשאתה צריך טיפוס תאריך אמיתי, הוסף שתי שורות:
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// פולט ISO-8601 בסריאליזציה במקום מערכי טיימסטאמפ
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public class Root {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
}
הבנאי ללא פרמטרים הוא בדיקת החובה האחרת: Jackson מבצע deserialization על ידי יצירת האובייקט דרכו ואז הצבת השדות אחד אחרי השני. תכתוב בנאי עם פרמטרים וברירת המחדל נעלמת — deserialization נכשל מיד. עם Lombok, הוסף @NoArgsConstructor; עם record ב-Java 17, העבר את כל הבעיה לתמיכת ה-records של Jackson 2.12+, שמתאים לפי פרמטרי הבנאי ומבטל את הדרישה לחלוטין.
7. שני ענייני ייצור: שתף את ה-ObjectMapper ואמת קלט
המחולל נותן לך את המחלקות; שני סעיפים ארכיטקטוניים נשארים בזמן ריצה. ראשית, יצירת ObjectMapper יקרה והוא thread-safe — שתף מופע אחד בכל היישום. קריאה ל-new ObjectMapper() בתוך מתודה היא האנטי-פטרן הביצועי הנפוץ ביותר, והמופע ש-Spring Boot מזריק כברירת מחדל קיים בדיוק מהסיבה הזאת. אסטרטגיית שמות, JavaTimeModule ומדיניות שדות לא מוכרים — הכל שייך למופע האחד ההוא, לא מפוזר בין נקודות קריאה.
שנית, deserialization מוצלח אומר שהטיפוסים תואמים, לא שהערכים חוקיים. מחיר שלילי, כמות אפס, מייל מעוות — ל-Jackson לא משנה. אימות קלט שייך ל-Bean Validation (hibernate-validator), מתווסף כהערות על השלד המיוצר:
public class CreateOrderRequest {
@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;
@Min(1)
private Integer qty;
@Email
private String contact;
}
ב-Spring, @Valid חוסם את הבקשה לפני שהיא מגיעה ללוגיקה העסקית. השכבה הזאת דבר שמחולל לעולם לא יוכל לספק — הוא יכול לקרוא מהדגימה איך שדה נראה, אבל לא איזה אילוץ עסקי השדה נושא.
8. איפה נגמר הייצור ומתחיל הכתיבה הידנית
מתי לייצר ומתי לכתוב ידנית הוא קו ברור:
- השתמש במחולל: תגובות עם הרבה שדות, אינטגרציות חד-פעמיות עם צד שלישי, אבוטיפים מהירים. הוא מחסל בדיוק מחלקה אחת של שגיאות — טעויות העתקה מכניות — ומוסר לך בשניות שלד מיושר שמות וטיפוסים.
- כתוב ידנית או שדרג לעומק: כסף ומספרים שלמים ארוכים דורשים שינוי טיפוס, אסטרטגיית שמות דורשת החלטה גלובלית, אובייקטי דומיין מרכזיים דורשים הערות אימות. אלה החלטות עסקיות שמטען דגימה לא יכול לקבל.
- תנאי מוקדם משותף לשניהם: ודא קודם שה-JSON המקור תקין. מחולל מייצר מחלקות ממטען שבור בשמחה ומשאיר את המלכודת לזמן ה-deserialization; העבר אותו קודם דרך עמוד האימות שלנו. עשר שניות, בכל מקרה.
המחולל מכסה "מאפס לשלד". חמשת התיקונים במאמר הזה מכסים "משלד לייצור". הסדר לא ניתן להיפוך, ואף שלב לא ניתן לדילוג.
FAQ
אפשר להשתמש במחלקות המיוצרות ישירות ב-Spring Boot?
בזמן קימפול, כן. לפני ריצה שלושה תוספות שוות: שדות כספיים ל-BigDecimal, אסטרטגיית שמות או @JsonProperty ל-API ב-snake_case, ו-@JsonIgnoreProperties(ignoreUnknown = true) לשרוד התפתחות API. הערות Bean Validation על פי כללי העסק שלך.
למה המחולל ממפה כל מספר ל-Double במקום long או BigDecimal?
דגימה מבדילה "שלם מול עשרוני" אבל לא משמעות עסקית. 2 יכולה להיות כמות או מזהה; מספר הזמנה בן עשרים ספרות ו-¥19.99 שניהם פשוט מספרים ב-JSON. להשאיר החלטות סמנטיות למשתמש הוא גבול סביר למחולל קדימה — המחיר הוא שטבלת ההחלפות בסעיף 2 עליך לעבור בעצמך.
@Data של Lombok או getters/setters כתובים ידנית — איזה?
אם הצוות כבר משתמש ב-Lombok, קח @Data לכמות הקוד החוזר הנמוכה ביותר. אם אתה מעדיף להימנע מתוסף זמן קימפול או התנגשויות עם מעבדי הערות אחרים, כתוב ידנית. ל-Jackson אין הבדל; הוא מסתכל על getters/setters או נראות שדות.
אובייקטים מקוננים הופכים למחלקות פנימיות סטטיות — האם זה משנה?
לא ל-Jackson; מחלקה פנימית סטטית יכולה להיות מומחשת באופן עצמאי. לתחזוקה ארוכת טווח, רוב הצוותים מוציאים אותם כמחלקות עליונות לחבילה, לשימוש חוזר ובדיקות מבודדות. מבנה המחלקות הפנימיות של המחולל נבנה לצפייה מהירה, לא לקודבייס חי.
שדה List זורק ClassCastException במהלך deserialization — איך מאתרים?
קודם בדוק אם ה-deserialization השתמש ב-List.class עירום: האיברים בפועל LinkedHashMap והשגיאה מזנקת בקריאת שדה. עבור ל-TypeReference. אם עדיין נכשל, הדבק את החריגה בעמוד חיפוש הודעות שגיאה שלנו ועקוב אחר הכניסה לשפה שלך.