Spring Data JPA

تجريد المستودع

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

تجريد المستودع

إنّ أكبر مكسب في الإنتاجية الذي تجلبه Spring Data JPA هو تجريد المستودع (Repository Abstraction): تُعلن عن واجهة برمجية، وتوسّع إحدى واجهات المستودع المدمجة في Spring Data، فتحصل فورًا على مجموعة كاملة من توابع الوصول إلى البيانات — دون أي كود تطبيقي زائد. إنّ الفهم الدقيق لما تحصل عليه، وكيفية عمل الآلية من الداخل، وأين تختبئ مزالق الأداء — هو ما يُميّز المطوّر الذي يستخدم Spring Data بمهارة عن الذي يكتفي باستخدامها.

هرمية الواجهات الأساسية

تُوفّر Spring Data ثلاث واجهات مستودع ستتعامل معها مباشرة، مُرتّبة في هرمية من القدرات المتصاعدة:

  • Repository<T, ID> — واجهة العلامة الجذر. لا تحتوي على أي توابع. مفيدة حين تريد الاستفادة من بنية Spring Data (المسح، إنشاء البروكسي) لكنك تُعرّض فقط التوابع التي تُعلنها بنفسك.
  • CrudRepository<T, ID> — تمتدّ من Repository وتُضيف 12 عملية CRUD قياسية.
  • JpaRepository<T, ID> — تمتدّ من PagingAndSortingRepository (التي تُضيف الترقيم) وCrudRepository، ثم تُضيف عمليات خاصة بـ JPA: الحفظ الدفعي، والتحكم في الإخراج الفوري (flush)، ومراجع الكيانات.
قاعدة الإبهام للاختيار: فضّل JpaRepository في معظم مستودعات الإنتاج — ستحصل على كل شيء. انزل إلى CrudRepository حين تريد إخفاء توابع الترقيم والترتيب من المُستدعين لمنع تحميل الجداول الكبيرة بالكامل عن غير قصد.

تعريف أول مستودع لك

افترض أن لديك كيان Order بمفتاح أساسي من نوع Long. تعريف المستودع يتلخّص في واجهة واحدة:

package com.example.shop.repository; import com.example.shop.entity.Order; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderRepository extends JpaRepository<Order, Long> { // Spring Data تُولّد التطبيق تلقائيًا عند بدء التشغيل }

هذا كل شيء. تُحلّل التهيئة التلقائية في Spring Boot حزمك، تعثر على هذه الواجهة، تُولّد بروكسي ديناميكي من نوع JDK يُطبّقها، وتُسجّله كـ Spring bean. تحقنه كأي bean آخر:

@Service public class OrderService { private final OrderRepository orders; public OrderService(OrderRepository orders) { this.orders = orders; } }

ما الذي يمنحك إياه CrudRepository مجانًا

بتوسيع CrudRepository (الذي تتضمّنه JpaRepository)، تحصل فورًا على هذه التوابع دون كتابة سطر واحد من SQL أو JPQL:

  • save(S entity) — يحفظ كيانًا جديدًا أو يدمج كيانًا موجودًا. يُعيد الكيان المُدار.
  • saveAll(Iterable<S> entities) — حفظ/دمج دفعي.
  • findById(ID id) — يُعيد Optional<T>. لا يُعيد null أبدًا؛ يُمثّل الغياب بشكل صريح.
  • existsById(ID id) — يُنفّذ SELECT COUNT — أرخص من تحميل الكيان بالكامل للتحقق من وجوده.
  • findAll() — يحمّل كل صف في الجدول. استخدمه بحذر مع الجداول الكبيرة.
  • findAllById(Iterable<ID> ids) — يحمّل مجموعة محددة من الكيانات بمفاتيحها الأساسية باستخدام جملة SQL من نوع IN.
  • count() — يُعيد العدد الإجمالي للصفوف.
  • deleteById(ID id) — يحذف بالمفتاح الأساسي؛ يرمي EmptyResultDataAccessException إذا لم يكن المعرّف موجودًا.
  • delete(T entity) — يحذف الكيان المُعطى؛ يجب أن يكون الكيان مُدارًا أو منفصلًا مع معرّف صحيح.
  • deleteAllById(Iterable<ID> ids) — يحذف بمجموعة معرّفات.
  • deleteAll(Iterable<T> entities) — يحذف الكيانات المُعطاة.
  • deleteAll() — يحذف كل صف. نادرًا ما تحتاجه في الإنتاج.

التوابع الإضافية في JpaRepository

تذهب JpaRepository أبعد من ذلك بعمليات خاصة بـ JPA:

  • findAll(Sort sort) / findAll(Pageable pageable) — استعلامات مُرتّبة أو مُرقَّمة (تُعالَج في درس الترقيم).
  • saveAndFlush(S entity) — يحفظ ويُخرج فورًا إلى قاعدة البيانات ضمن المعاملة الحالية. مفيد في الاختبارات التي تحتاج التحقق من القيود قبل إتمام المعاملة.
  • saveAllAndFlush(Iterable<S> entities) — حفظ دفعي ثم إخراج فوري.
  • deleteAllInBatch() / deleteAllByIdInBatch(Iterable<ID> ids) — يُنفّذ جملة DELETE SQL دفعية واحدة بدلًا من تحميل الكيانات واستدعاء delete على كل منها. أسرع بكثير للحذف الجماعي، لكنه يتجاوز ردود أفعال دورة حياة Hibernate والتسلسلات.
  • getReferenceById(ID id) — يُعيد بروكسي Hibernate دون الاستعلام عن قاعدة البيانات. مفيد حين تحتاج مرجع كيان فقط لتلبية مفتاح خارجي على كيان آخر على وشك الحفظ.
  • flush() — يُجبر Hibernate على مزامنة سياق الاستمرارية مع قاعدة البيانات فورًا.
استخدم getReferenceById لتجنّب SELECT غير الضرورية. إذا كنت تُعيّن حقل customer على Order جديد وتملك معرّف العميل بالفعل، استدعِ customerRepo.getReferenceById(customerId) بدلًا من findById. لن يُنفَّذ أي SELECT — سيُنشئ Hibernate بروكسي ولن يحلّه إلا إذا وصلت فعلًا إلى حقوله.

تابع save() — الكيانات الجديدة مقابل الموجودة

يبدو save(entity) بسيطًا لكن له سلوك مهم يجب فهمه:

// INSERT — كيان بدون معرّف (المفتاح الأساسي null) Order newOrder = new Order(); newOrder.setTotal(BigDecimal.valueOf(99.99)); Order saved = orders.save(newOrder); // saved.getId() مُعبَّأ الآن // UPDATE — كيان بمعرّف موجود saved.setTotal(BigDecimal.valueOf(109.99)); orders.save(saved); // يُصدر Hibernate جملة UPDATE

خلف الكواليس، تتحقق SimpleJpaRepository في Spring Data ممّا إذا كان الكيان جديدًا باستدعاء EntityInformation.isNew(entity). افتراضيًا يتحقق هذا من كون حقل المعرّف null. عند الجدّة يستدعي EntityManager.persist()؛ وعند الوجود يستدعي EntityManager.merge().

احذر من دلالات merge مع الكيانات المنفصلة. استدعاء save(entity) على كيان منفصل يُشغّل merge()، الذي ينسخ الحالة إلى نسخة مُدارة جديدة ويُعيدها. اعمل دائمًا مع النسخة المُعادة — التغييرات التي تُجريها على الكائن المنفصل الأصلي بعد الاستدعاء لن تُحفظ.

deleteAllInBatch مقابل deleteAll — تمييز حرج للأداء

هذا من أهم المفاضلات في Spring Data:

  • deleteAll() — يُحمّل كل كيان منفردًا (استعلام SELECT واحد لكل كيان ما لم يكن جلب الدُفعات مُهيَّأً)، ويُطلق أحداث دورة حياة Hibernate (@PreRemove، والتسلسلات، وحذف الأيتام) لكل منها، ثم يُصدر DELETE واحدًا لكل كيان. آمن وصحيح، لكنه O(n) من رحلات قاعدة البيانات.
  • deleteAllInBatch() — يُنفّذ جملة DELETE FROM orders واحدة دون أي تحميل. سريع للغاية، لكنه يتجاوز جميع تسلسلات Hibernate وردود أفعال دورة الحياة.

استخدم الحذف الدفعي حين تُنظّف جداول مرحلية أو بيانات اختبار أو مجموعات بدون تبعيات تسلسلية. استخدم deleteAll() حين يُهمّ السلوك الصحيح للتسلسل.

كيف تُولّد Spring Data التطبيق

عند بدء التشغيل تُنشئ Spring Data كائن SimpleJpaRepository<T, ID> مدعومًا بـ EntityManager لكل واجهة مستودع. تُعلَّق هذه الفئة بـ @Transactional(readOnly = true) على مستوى الفئة، مما يعني أن جميع توابع الاستعلام تعمل في معاملة للقراءة فقط افتراضيًا — وهو تحسين أداء حقيقي في Hibernate إذ يُعطّل التحقق من التعديلات (dirty checking) على الكيانات المحمَّلة. أما توابع الكتابة (save، delete) فمُعلَّقة منفردةً بـ @Transactional للقراءة والكتابة، ما يُعيد تعريف الإعداد على مستوى الفئة.

ترث الإعدادات الافتراضية للمعاملات مجانًا. لست بحاجة إلى تعليق توابع الخدمة بتعليقات توضيحية لمجرد تشغيل بحث أو حفظ بسيط — طبقة المستودع تُدير المعاملة بالفعل. تحتاج @Transactional على تابع الخدمة فقط حين يجب أن يمتد على عدة استدعاءات مستودع في وحدة عمل واحدة.

مثال عملي — OrderRepository في الاستخدام

@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orders; private final CustomerRepository customers; // findById يُعيد Optional — تعامل مع الغياب بشكل صريح public Order getOrThrow(Long id) { return orders.findById(id) .orElseThrow(() -> new EntityNotFoundException("Order " + id)); } // ربط علاقة فعّال عبر البروكسي — لا SELECT على Customer @Transactional public Order createOrder(Long customerId, BigDecimal total) { Order order = new Order(); order.setCustomer(customers.getReferenceById(customerId)); order.setTotal(total); return orders.save(order); } // تنظيف جماعي — جملة DELETE واحدة @Transactional public void clearDraftOrders(List<Long> draftIds) { orders.deleteAllByIdInBatch(draftIds); } }

الخلاصة

يُلغي تجريد المستودع طبقة DAO المكتوبة يدويًا بالكامل. تُغطّي CrudRepository العمليات الاثنتي عشرة الأساسية؛ وتُضيف JpaRepository الترقيم والتحكم في الإخراج الفوري والعمليات الدفعية المحسَّنة لـ JPA. تُولّد Spring Data بروكسي SimpleJpaRepository عند بدء التشغيل، مع الافتراضي على معاملات للقراءة فقط للاستعلامات ومعاملات للقراءة والكتابة للتعديلات. أتقن الفرق بين دلالات save/merge، والفجوة في الأداء بين deleteAll وdeleteAllInBatch، وحيلة الـ SELECT الصفرية باستخدام getReferenceById — هذه هي التفاصيل التي تُحدث فرقًا في الإنتاج. في الدرس القادم ستوسّع هذا الأساس بتوابع الاستعلام المشتقة التي تتيح لك الاستعلام بأي حقل كياني دون كتابة JPQL.