Spring Data JPA

ترقيم الصفحات والترتيب

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

ترقيم الصفحات والترتيب

حين ينمو الجدول إلى آلاف أو ملايين السجلات، تحميل كل سجل في الذاكرة لطلب واحد أمر بطيء ومُكلف. تحلّ Spring Data JPA هذه المشكلة بأناقة عبر مجردَّتين — Pageable وSort — وتُغلّف النتيجة في كائن Page<T> غني يحمل البيانات وبيانات التنقّل التي تحتاجها واجهة المستخدم.

المجردّات الأساسية

تُقدّم Spring Data ثلاثة أنواع تعمل معًا:

  • Sort — تصف جملة ORDER BY: خاصية واحدة أو أكثر، لكل منها اتجاه (ASC أو DESC). غير قابلة للتغيير وقابلة للتركيب.
  • Pageable — تجمع رقم الصفحة (يبدأ من صفر) وحجم الصفحة وكائن Sort اختياري. التنفيذ الأكثر شيوعًا هو PageRequest.
  • Page<T> — نتيجة استعلام مُرقَّم. تمتد من Slice<T> وتُضيف إجمالي العناصر وعدد الصفحات، مما يستلزم استعلام COUNT(*) إضافيًا.
Page مقابل Slice: يُصدر Page<T> دائمًا استعلام COUNT ثانيًا ليعرف إجمالي السجلات والصفحات. أما Slice<T> فيتخطى العدّ ولا يعرف إلا ما إذا كانت هناك شريحة تالية. بالنسبة لواجهات التمرير اللانهائي أو التنقل بالمؤشر، Slice أرخص. أما للمرقّم التقليدي الذي يعرض "صفحة 3 من 47" فأنت بحاجة إلى Page.

تفعيل ترقيم الصفحات في Repository

أضف Pageable كمعامل لأي دالة في الـ repository وأعد Page<T> أو Slice<T>. تتولى Spring Data JPA الباقي — تُحقن LIMIT وOFFSET (أو ما يعادلهما) واستعلام COUNT الملفوف لـ Page.

import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductRepository extends JpaRepository<Product, Long> { // كل المنتجات، مُرقَّمة Page<Product> findAll(Pageable pageable); // مفلتَرة + مُرقَّمة — الاستعلام المشتق يقبل Pageable أيضًا Page<Product> findByCategory(String category, Pageable pageable); }

تُوفّر قاعدة JpaRepository بالفعل findAll(Pageable)، لذا لا تحتاج إلى تعريفها إلا حين تُضيف معاملات تصفية.

بناء PageRequest

استخدم الدوال المصنعية الساكنة في PageRequest:

import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; // الصفحة 0، 20 عنصرًا لكل صفحة، بدون ترتيب Pageable first20 = PageRequest.of(0, 20); // الصفحة 2، 10 عناصر، مرتبة بحسب السعر تصاعديًا Pageable page2ByPrice = PageRequest.of(2, 10, Sort.by("price")); // أعمدة ترتيب متعددة: name تصاعدي، ثم price تنازلي Sort multiSort = Sort.by(Sort.Order.asc("name"), Sort.Order.desc("price")); Pageable complex = PageRequest.of(0, 15, multiSort);
أرقام الصفحات تبدأ من الصفر. الصفحة 0 هي الأولى. إذا كانت واجهة API REST تعرض صفحات تبدأ من 1 للعملاء، اطرح 1 قبل التمرير إلى PageRequest.of(): PageRequest.of(apiPage - 1, size).

التعامل مع نتيجة Page

يحتوي كائن Page<T> على كل ما تحتاجه واجهة القائمة:

Page<Product> result = productRepository.findByCategory("electronics", pageable); List<Product> items = result.getContent(); // بيانات الصفحة الحالية int pageNum = result.getNumber(); // الصفحة الحالية (يبدأ من 0) int pageSize = result.getSize(); // عناصر لكل صفحة long totalItems = result.getTotalElements(); // إجمالي السجلات في قاعدة البيانات int totalPages = result.getTotalPages(); // ceil(total / size) boolean hasNext = result.hasNextPage(); boolean hasPrev = result.hasPreviousPage(); boolean isFirst = result.isFirst(); boolean isLast = result.isLast();

في متحكم Spring MVC أو REST يمكنك إعادة هذا الكائن مباشرةً:

import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/products") public class ProductController { private final ProductRepository repo; public ProductController(ProductRepository repo) { this.repo = repo; } @GetMapping public Page<Product> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "id") String sortBy) { Sort sort = Sort.by(Sort.Direction.ASC, sortBy); Pageable pageable = PageRequest.of(page, size, sort); return repo.findAll(pageable); } }

يُحوّل مُسلسل Jackson في Spring كائن Page<T> إلى غلاف JSON يتضمن content وtotalElements وtotalPages وnumber والمزيد — وهو بالضبط ما تحتاجه الواجهة الأمامية لعرض مرقّم الصفحات.

الترتيب بدون ترقيم الصفحات

أحيانًا تريد نتائج مرتبة بدون حد للصفحات. مرّر كائن Sort مباشرةً:

import org.springframework.data.domain.Sort; import java.util.List; List<Product> allByPrice = productRepository.findAll( Sort.by(Sort.Order.asc("price"))); List<Product> recent = productRepository.findByCategory( "books", Sort.by(Sort.Direction.DESC, "createdAt"));

استخدام @Query مع ترقيم الصفحات

تقبل الاستعلامات المخصصة بـ JPQL أيضًا Pageable. ضع تعليق @Query على الدالة وأضف معامل Pageable كآخر وسيط:

import org.springframework.data.jpa.repository.Query; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface ProductRepository extends JpaRepository<Product, Long> { @Query("SELECT p FROM Product p WHERE p.price < :maxPrice AND p.stock > 0") Page<Product> findAvailable(@Param("maxPrice") BigDecimal maxPrice, Pageable pageable); }

تُحقن Spring Data جملة ORDER BY وLIMIT من Pageable في SQL المولَّد تلقائيًا. لاستعلام العدّ تُغلّف JPQL في SELECT COUNT(p) ما لم تُوفّر خاصية countQuery مخصصة.

تجنب الترتيب بأعمدة غير مُفهرَسة في الجداول الكبيرة. سيُجبر Sort.by("description") على عمود TEXT بملايين السجلات قاعدةَ البيانات على إجراء filesort كامل. أنشئ فهارس للأعمدة التي تُرتّب بها — خاصة createdAt وحقول الحالة التي كثيرًا ما تُستخدم هدفًا للترتيب في واجهات القوائم.

الربط التلقائي مع Web MVC بـ Pageable

يستطيع Spring MVC ربط Pageable مباشرةً من معاملات الطلب إذا أضفت @EnableSpringDataWebSupport إلى إعداداتك (يُفعَّل تلقائيًا في Spring Boot):

// الطلب: GET /products?page=1&size=10&sort=price,desc&sort=name,asc @GetMapping public Page<Product> list(Pageable pageable) { return repo.findAll(pageable); }

يمكن أن يظهر معامل sort أكثر من مرة للترتيب متعدد الأعمدة. هذا يتيح للواجهة الأمامية التحكم في الترقيم والترتيب بدون أي كود خاص في المتحكم.

ملاحظات الأداء

  • تكلفة OFFSET: يقرأ LIMIT 20 OFFSET 10000 ويتجاهل 10,000 صف في معظم قواعد البيانات. للصفحات العميقة جدًا، فكّر في ترقيم الصفحات بالمؤشر (keyset pagination) بدلًا من ذلك.
  • استعلام COUNT: كل نتيجة Page تُطلق استعلامَي SQL. إذا كان العدّ الإجمالي مكلفًا (مثل الانضمام إلى عدة جداول)، انتقل إلى Slice وأخفِ الإجمالي عن المستخدمين، أو خزّن العدد في الذاكرة المؤقتة.
  • Fetch joins وترقيم الصفحات: يُسجّل Hibernate تحذيرًا حين تمزج fetch join (الذي يُضخّم الصفوف) مع Pageable. يُنفّذ الترقيم في الذاكرة بدلًا من SQL، مما قد يكون كارثيًا على مجموعات البيانات الكبيرة. الحل: رقّم المعرّفات أولًا، ثم جلب الكيانات الكاملة بتلك المعرّفات.

الخلاصة

PageRequest.of(page, size, sort) هي نقطة دخولك. مرّرها إلى أي دالة في الـ repository تُعيد Page<T> وستحصل على البيانات وحالة التنقل والإجماليات في كائن واحد. استخدم Sort.by() وحده حين تحتاج الترتيب بدون حد للصفحات. انتبه للمطبَّين الخاصَّين بالأداء — تكلفة OFFSET العميق وتعارضات fetch join — وستمتلك طبقة بيانات قابلة للتوسع وسهلة الصيانة.