علاقات الكيانات والارتباطات

أنواع الجلب: EAGER مقابل LAZY

18 دقيقة الدرس 6 من 13

أنواع الجلب: EAGER مقابل LAZY

كل علاقة ترسمها في Hibernate — سواء كانت @OneToOne أو @OneToMany أو @ManyToOne أو @ManyToMany — تحمل استراتيجية جلب تجيب على سؤال واحد: متى ينبغي لـ Hibernate تحميل البيانات المرتبطة؟ والإجابة لها تداعيات عميقة على الصحة واستهلاك الذاكرة وأداء الاستعلامات. الخطأ في هذا الأمر هو أحد أكثر أسباب بطء تطبيقات Spring Boot شيوعًا.

الاستراتيجيتان

الجلب الفوري (EAGER) يُحمّل العلاقة فورًا ضمن العملية ذاتها التي تُحمّل فيها الكيان المالك. إذا حمّلت كيان Customer وكانت مجموعة orders بنوع EAGER، فسيُنفّذ Hibernate ما يلزم من SQL لملء تلك المجموعة فورًا — لن تمتلك أبدًا كائن Customer لم تُهيَّأ فيه قائمة طلباته.

الجلب الكسول (LAZY) يُؤجّل التحميل. يمنحك Hibernate كائن بروكسي (لعلاقة واحدة) أو مجموعة مغلّفة غير مُهيَّأة (للمجموعات). يُطلَق SQL الحقيقي فقط عند أول مرة يصل فيها كودك إلى هذا البروكسي أو يتكرر على المجموعة.

القيم الافتراضية في Hibernate

تحدّد مواصفة JPA القيم الافتراضية ويلتزم Hibernate بها:

  • @ManyToOne — الافتراضي EAGER
  • @OneToOne — الافتراضي EAGER
  • @OneToMany — الافتراضي LAZY
  • @ManyToMany — الافتراضي LAZY
القيم الافتراضية EAGER في @ManyToOne و@OneToOne فخّ حقيقي. تبدو غير ضارة مع كيان واحد، لكنها تتحوّل إلى كارثة أداء حين تُحمّل قوائم من الكيانات. هذا هو السبب الجذري لمشكلة استعلامات N+1 التي ستدرسها في الدرس التالي.

يمكنك تجاوز القيمة الافتراضية باستخدام الخاصية fetch:

import jakarta.persistence.*; @Entity public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // تجاوز الافتراضي EAGER — حمّل العميل فقط عند الحاجة @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // إبقاء الافتراضي LAZY صريحًا للوضوح @OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL) private List<OrderLine> lines = new ArrayList<>(); }

كيف يعمل LAZY في الممارسة

حين تُحدّد علاقةً بـ LAZY، يستبدل Hibernate الكائن بـ بروكسي (proxy object) (لعلاقات @ManyToOne / @OneToOne) أو بـ PersistentBag / PersistentList غير مُهيَّأة (للمجموعات). يشبه البروكسي النوع الحقيقي لكنه لا يحمل إلا المفتاح الأساسي. بمجرد استدعاء أي دالة getter غير معرِّفة، يُطلق Hibernate SQL ويستبدل البروكسي بالبيانات الحقيقية.

// داخل دالة خدمة مُعلَّقة بـ @Transactional: Order order = orderRepository.findById(1L).orElseThrow(); // عند هذه النقطة: order.customer هو بروكسي — لا SQL للعميل بعد System.out.println(order.getId()); // مجرد المعرّف، لا تهيئة للبروكسي String name = order.getCustomer().getName(); // الآن يُطلق Hibernate: // SELECT * FROM customers WHERE id = ?
تهيئة البروكسي تستلزم جلسة مفتوحة. بمجرد إغلاق جلسة Hibernate (وحدة العمل خلف المعاملة)، يؤدي أي محاولة للوصول إلى بروكسي غير مُهيَّأ إلى رمي LazyInitializationException. وهي أكثر أخطاء Hibernate شيوعًا في وقت التشغيل.

LazyInitializationException — الخطأ الكلاسيكي

يظهر هذا الخطأ حين تُحمّل كيانًا داخل دالة خدمة، ثم تُعيده إلى متحكم أو مُسلسِل (serializer) يعمل خارج المعاملة، فيحاول المُسلسِل اجتياز مجموعة غير مُهيَّأة:

// خاطئ: تنتهي المعاملة عند إعادة findById؛ يحاول Jackson تسلسل // order.lines بعد انتهاء الجلسة @GetMapping("/orders/{id}") public Order getOrder(@PathVariable Long id) { return orderRepository.findById(id).orElseThrow(); // تنتهي الجلسة هنا } // يستدعي Jackson ‏order.getLines()‏ ← LazyInitializationException

ثمة ثلاثة حلول صحيحة مرتّبة تنازليًا حسب الأفضلية:

  1. استخدم DTO — اسقط فقط ما يحتاجه المُستدعي داخل المعاملة؛ لا تكشف الكيانات من المتحكمات.
  2. استخدم استعلام JOIN FETCH — حمّل بالقوة العلاقات التي تحتاجها لهذه الحالة تحديدًا (يُغطّى في الدرس 8).
  3. أضف @Transactional على دالة الخدمة — يُبقي الجلسة مفتوحة أثناء الوصول إلى البيانات (انتبه للنمط المضاد Open-Session-in-View أدناه).

Open-Session-in-View — الإعداد الافتراضي المثير للجدل

يُفعّل Spring Boot بشكل افتراضي Open Session in View (OSIV) عبر الإعداد spring.jpa.open-in-view=true. يُبقي هذا جلسة Hibernate مفتوحة طوال دورة حياة طلب HTTP بأكملها، بما فيها مرحلة عرض النتيجة / تسلسل JSON. يُخفي هذا الإعداد LazyInitializationException لكن بتكلفة عالية: يظل اتصال قاعدة البيانات محجوزًا طوال مدة الطلب، بما في ذلك أي استدعاءات خارجية أو عرض بطيء يحدث بعد عودة طبقة الخدمة.

أوقف OSIV في خدمات الإنتاج. اضبط spring.jpa.open-in-view=false في application.properties. ستظهر لديك LazyInitializationExceptions في البداية — عاملها كإشارات تشخيصية تخبرك بالضبط أيّ العلاقات تحتاج JOIN FETCH أو إسقاط DTO. هذا الانضباط يُكافئك بضغط أقل على مجموعة الاتصالات وزمن استجابة أكثر قابلية للتنبؤ.

اختيار الاستراتيجية المناسبة

القاعدة التي يتبعها مطورو Hibernate ذوو الخبرة:

  • استخدم LAZY دومًا كقيمة افتراضية لجميع العلاقات. اجلب ما تحتاجه صراحةً لكل استعلام على حدة.
  • EAGER على مستوى الرسم يناسب فقط كائنات القيم الصغيرة جدًا التي تُحتاج دائمًا ولن تنمو أبدًا (نادر جدًا).
  • اجلب البيانات بالقوة لكل استعلام باستخدام JOIN FETCH في JPQL أو Entity Graphs — لا بتغيير الرسم.
// application.properties — إعدادات جيدة لأي تطبيق إنتاجي spring.jpa.open-in-view=false # تسجيل SQL في Hibernate (مفيد أثناء التطوير) spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true

EAGER قد يُخفي ضربات ديكارتية

حين يمتلك كيان ما مجموعتَين أو أكثر بنوع EAGER، قد يضم Hibernate كليهما في استعلام SQL واحد مما ينتج عنه ضرب ديكارتي (Cartesian product): إذا كان لدى Customer عشرة orders ولكل طلب خمسة lines، سيُعيد الضم 50 صفًا لعميل واحد. يُزيل Hibernate التكرارات في الذاكرة، لكن قاعدة البيانات أجرت 50 مرة من العمل. كثيرًا ما يكون هذا غير ملحوظ أثناء التطوير (بيانات صغيرة) وكارثيًا في الإنتاج.

الخلاصة

نوع الجلب هو المقبض الذي يتحكم في متى يُطلَق SQL للبيانات المرتبطة. ينبغي تجاوز القيم الافتراضية في JPA — EAGER لـ @ManyToOne و@OneToOne، وLAZY للمجموعات — لجعل كل شيء افتراضيًا LAZY. اجلب العلاقات بالقوة فقط حين تحتاجها فعلًا لعملية محددة، باستخدام آليات لكل استعلام على حدة كـ JOIN FETCH أو Entity Graphs بدلًا من تغيير الرسم. أوقف Open-Session-in-View، وعامل LazyInitializationException كمشخّص، وفضّل إسقاطات DTO. الدرسان التاليان يترجمان هذه المبادئ إلى أنماط JPQL وEntity Graph ملموسة.