من JSON إلى فئة Java: المولّد يعطيك الهيكل فقط، والإنتاج يحتاج خمسة أشياء إضافية
الخلاصة: نظرة عملية على ما يصدره مولّد JSON-to-Java حقاً — الأرقام تصبح Double، والكائنات المتداخلة تصبح فئات داخلية static، ولا تُنتج أي تعليق Jackson توضيحي واحد. دقة المال، إعادة تسمية الحقول، محو الأنواع، الحقول المجهولة والتواريخ هي الأشياء الخمسة التي عليك إضافتها بنفسك، مع كود قابل للنسخ لكل واحدة.

الخلاصة: ناتج المولّد يُترجم فوراً لكنه ليس جاهزاً للإنتاج. القيم الافتراضية المقيسة لمولّدنا تحوّل كل رقم JSON إلى Double، وتحوّل الكائنات المتداخلة إلى فئات داخلية static، ولا تصدر أي تعليق توضيحي من Jackson على الإطلاق. دقة المال، إعادة تسمية الحقول، محو الأنواع، الحقول المجهولة والتواريخ — خمسة أشياء لن يفعلها أبداً نيابة عنك.
تحويل استجابة API إلى فئات Java هو عمل ميكانيكي: اقرأ JSON، انسخ الحقول، غلّفها بـ getter و setter. كلفة العمل الميكانيكي هي تلك السكّة الخفية — اسم خاصية منسوخ بخطأ حرف واحد لا يسبب خطأ ترجمة. الحقل يصل بصمت بقيمة null، وتكتشف ذلك في سجل الإنتاج. توليد هيكل الفئات موجود ليتوائم الأسماء والأنواع من اليوم الأول. لكن المولّد لا يستطيع التوائم إلا ما يظهر في العينة.
1. شغّل المولّد أولاً: ما الذي يعطيك إياه فعلاً
الصق استجابة ممثِّلة في أداة JSON إلى 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 فئتان داخليتان static منفصلتان. قواعد التحويل، واحدة تلو الأخرى:
| الشكل في JSON | نوع Java المولَّد | ملاحظات |
|---|---|---|
"name": "王五" |
String |
السلاسل تصبح String دائماً |
"qty": 2 |
Double |
كل رقم يصبح Double، انظر القسم ٢ |
"vip": true |
Boolean |
نوع مغلّف، يُسمح بـ null |
"buyer": {...} |
Buyer (فئة داخلية static) |
الكائنات المتداخلة تصبح فئة خاصة بها |
"items": [...] |
List<Item> |
نوع العنصر يؤخذ من أول عنصر |
order_id |
orderId |
snake_case يتحول إلى camelCase |
أسماء الفئات تأخذ PascalCase (Root)، والحقول camelCase (orderId)، والتصادمات مع الكلمات المفتاحية لجافا (class, new, int) تحصل تلقائياً على لاحقة. توجد ثلاثة خيارات: packageName و className و useLombok التي تتنقل بين @Data والـ getters/setters المكتوبة يدوياً. مع تفعيل Lombok، ينكمش كتلة الكود المتكرر كلها إلى بضعة تعليقات توضيحية.
حدّان لهذا الهيكل يستحقان المعرفة مسبقاً. أولاً، لا يولّد أي تعليق توضيحي من Jackson/Gson — لا @JsonProperty ولا @JsonIgnoreProperties ولا @JsonFormat، لأن التعليقات التوضيحية تقرر كيف تُنفَّذ إزالة التسلسل، ونموذج بيانات عينة لا يمكنه كشف سياسة التنفيذ لديك. ثانياً، لا يوجد تحويل عكسي: اشتقاق JSON من فئة Java خارج النطاق، وهو التصميم الأحادي الاتجاه ذاته لمسار JSON إلى 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 وحدها تبدو نظيفة لأن جافا تطبّق تجميل أقصر تمثيل؛ الخطأ يظهر لحظة دخول القيمة في عملية حسابية. المقالات الهندسية الثلاثة حول هذا الموضوع متفقة: الأسعار والمبالغ والمعدلات يجب أن تكون 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 في جهة جافا. لكن جاكسون يطابق مفاتيح 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 تعتبر هذا بالضبط سبب وجود المولّدات: النسخ الميكانيكي هو بالضبط المكان الذي تتذرو فيه نسبة هذا الخطأ. لذا بعد إضافة التعليقات التوضيحية، اكتب اختبار تأكيد واحد على الأقل بأن الحقول المفتاحية ليست null.
4. الإصلاح الثالث: محو الأنواع — لا تحوّل إزالة التسلسل إلى List مجردة أبداً
المولّد يسلّمك إعلان الحقل List<Item>، لكن الشيء الذي يملأ تلك القائمة فعلاً هو Jackson. إزالة التسلسل في المستوى الأعلى بهذا الشكل تسقط مباشرة في فخ محو الأنواع:
// خطأ: العناصر هي LinkedHashMap في وقت التشغيل
List<Item> items = mapper.readValue(json, List.class);
// صحيح: TypeReference يحفظ المعامل الجنريك
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
readValue(json, List.class) تُترجم وتعمل، لكن الجنريكس في جافا موجود فقط وقت الترجمة. في وقت التشغيل يرى Jackson مجرد List، فتهبط العناصر كـ LinkedHashMap. الخطأ لا ينطلق فوراً — ينطلق عند أول item.getSku() في مكان ما، رافعاً ClassCastException بعيداً جداً عن مكان وقوع الخطأ. ثلاث صيغ صحيحة، حسب السيناريو:
// ١. المستوى الأعلى هو المصفوفة نفسها
List<Item> items = mapper.readValue(json, new TypeReference<List<Item>>() {});
// ٢. حقل في كائن المستوى الأعلى
JsonNode root = mapper.readTree(json);
List<Item> items = mapper.convertValue(root.get("items"), new TypeReference<List<Item>>() {});
// ٣. بناء JavaType صريح
JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, Item.class);
List<Item> items = mapper.readValue(json, type);
في جهة Gson المقابل هو TypeToken. الأدلة الثلاثة كلها تسرد هذه بنداً مستقلاً، لأنها تفشل متأخراً وتُبلِّغ من بعيد.
5. الإصلاح الرابع: الحقول المجهولة — لا تدع تطور الـ API يكسر إزالة التسلسل
أن يضيف الـ API حقلاً هو أقصى تطور عادي. لكن السلوك الافتراضي لجاكسون هو رمي 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;
}
الباني بلا معاملات هو الفحص الإلزامي الآخر: جاكسون يزيل التسلسل بإنشاء الكائن عبره ثم ضبط الحقول واحداً تلو الآخر. اكتب بانيَ بمعاملات ويختفي الافتراضي — إزالة التسلسل تفشل فوراً. مع Lombok، أضف @NoArgsConstructor؛ مع record في جافا 17، سلّم المسألة كلها لدعم الـ records في Jackson 2.12+ الذي يطابق بمعاملات الباني ويزيل هذا الشرط تماماً.
7. مسألتان إنتاجيتان: أعد استخدام ObjectMapper وتحقق من المدخلات
المولّد يعطيك الفئات؛ بندان معماريان يبقيان وقت التشغيل. أولاً، إنشاء ObjectMapper مكلف وهو آمن للخيوط — شارك نسخة واحدة عبر التطبيق كله. استدعاء new ObjectMapper() داخل دالة هو أكثر أنماط الأداء المضادة شيوعاً، والنسخة التي يحقنها Spring Boot افتراضياً موجبة لهذا السبب بالذات. استراتيجية التسمية وJavaTimeModule وسياسة الحقول المجهولة كلها تعود لتلك النسخة الواحدة، لا مشتتة بين مواقع الاستدعاء.
ثانياً، إزالة التسلسل الناجحة تقول إن الأنواع طابقت، لا إن القيم قانونية. سعر سالب، كمية صفرية، بريد مشوَّه — لا يهم جاكسون. التحقق من المدخلات يعود إلى 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 المصدر صالح. المولّد ينتج فئات من بيانات مكسورة بكل سرور ويترك الفخ لوقت إزالة التسلسل؛ مرّره أولاً على صفحة التحقق لدينا. عشر ثوانٍ، في الحالتين.
المولّد يغطي «من الصفر إلى الهيكل». الإصلاحات الخمسة في هذا المقال تغطي «من الهيكل إلى الإنتاج». الترتيب لا يُعكس، ولا تُتخطى أي خطوة.
FAQ
هل يمكن استخدام الفئات المولَّدة مباشرة في Spring Boot؟
وقت الترجمة، نعم. قبل التشغيل تستحق ثلاث إضافات: حقول المال إلى BigDecimal، واستراتيجية تسمية أو @JsonProperty لواجهات snake_case، و@JsonIgnoreProperties(ignoreUnknown = true) للنجاة من تطور الـ API. تعليقات Bean Validation تتبع قواعد عملك.
لماذا يحوّل المولّد كل رقم إلى Double بدل long أو BigDecimal؟
العينة تفرّق بين «صحيح وعشري» لكنها لا تفرّق المعنى التجاري. 2 قد تكون كمية أو معرّفاً؛ رقم طلب من عشرين خانة و¥19.99 كلاهما مجرد number في JSON. ترك القرارات الدلالية للمستخدم حدود معقولة لمولّد أمامي — وثمنها أن جدول الاستبدال في القسم ٢ عليك مراجعته بنفسك.
@Data من Lombok أم getters/setters مكتوبة يدوياً — أيهما؟
إن كان الفريق يستخدم Lombok أصلاً فخذ @Data لأقل كود متكرر. إن كنت تفضّل تجنب إضافة وقت الترجمة أو تعارضات مع معالجات تعليقات أخرى فاكتبها يدوياً. لا فرق عند جاكسون؛ ينظر إلى getters/setters أو ظهور الحقول.
الكائنات المتداخلة تصبح فئات داخلية static — هل يهم؟
ليس عند جاكسون؛ الفئة الداخلية static يمكن استنساخها مستقلة. لكن للصيانة طويلة الأمد يستخرجها معظم الفرق كفئات عالية المستوى في حزمة، لإعادة الاستخدام والاختبار المعزول. بنية الفئات الداخلية للمولّد مبنية للمعاينة السريعة، لا لقاعدة كود حية.
حقل List يرمي ClassCastException أثناء إزالة التسلسل — كيف أحدّده؟
افحص أولاً ما إذا كانت إزالة التسلسل استخدمت List.class مجردة: العناصر في الواقع LinkedHashMap والخطأ يقفز عند قراءة حقل. انتقل إلى TypeReference. إن استمر الفشل، الصق الاستثناء في صفحة البحث في رسائل الأخطاء لدينا واتبع المدخل الخاص بلغتك.