خلاصه مطلب
در این مطلب قرار است یاد بگیریم چطور میتوانیم دو رویکرد مختلف — Paging و Skip/Top — را در یک پروژهی اسپرینگ بوت با استفاده از JPA و JOOQ پیادهسازی کنیم.
وقتی در برنامه نقاط پایانی (endpoint) میسازیم که مجموعهای از موجودیتها (entities) را برمیگردانند، خیلی مهم است که صفحهبندی را پیادهسازی کنیم. در غیر این صورت ممکن است با مشکلاتی مثل طولانی شدن زمان پاسخ، مصرف بیش از حد حافظه و حتی بعضی حملات (مثل حملات انکار سرویس با درخواستهای خیلی بزرگ) روبهرو شویم.
در این مطلب قرار است یاد بگیریم چطور میتوانیم دو رویکرد مختلف — Paging و Skip/Top — را در یک پروژهی اسپرینگ بوت با استفاده از JPA و JOOQ پیادهسازی کنیم.

مقایسهی Paging و Skip/Top
در روش Paging، نقطه پایانی (endpoint) طوری طراحی میشود که دو پارامتر دریافت کند: شمارهی صفحه (page number) و اندازهی صفحه (page size). این دو پارامتر مشخص میکنند کدام بخش از دادهها باید برگردانده شوند.

معمولاً در پاسخهای Paging علاوه بر دادههای انتخابشده، یکسری اطلاعات کمکی (metadata) هم وجود دارد؛ مثل اندازهی صفحه، تعداد کل رکوردها و شمارهی صفحهی فعلی.
از طرف دیگر، در روش Skip/Top دو پارامتر وجود دارد که نتیجهی مشابهی به دست میدهند. در این حالت شما مشخص میکنید چند رکورد باید رد شوند (skip) و چند رکورد باید برگردند (top). بهعنوان مثال، وقتی میگوییم skip 10 و top 20 یعنی ۱۰ رکورد اول کنار گذاشته شود و ۲۰ رکورد بعدی بازگردانده شود. این روش معمولاً فقط لیست دادهها را برمیگرداند و خبری از اطلاعات تکمیلی (metadata) نیست.
Controller
قبل از اینکه سراغ کد مخزن (Repository) برویم ـ جایی که اصل کار انجام میشود ـ بیایید نگاهی به کلاس Controller بیندازیم. این کلاس چهار endpoint دارد:
JPA همراه با صفحهبندی (pagination)
JPA همراه با skip و top
JOOQ همراه با صفحهبندی (pagination)
JOOQ همراه با skip و top
| @RestController @RequestMapping("/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; private final ProductServiceJooq productServiceJooq; @GetMapping("/jpa/withPage") @ResponseStatus(HttpStatus.OK) public Page findAll( @RequestParam(name = "page") int page, @RequestParam(name = "pageSize") int size) { return productService.findByPage(page, size); } @GetMapping("/jpa/withSkipAndTop") @ResponseStatus(HttpStatus.OK) public Iterable findWithSkipAndTop( @RequestParam(name = "skip") int skip, @RequestParam(name = "top") int top) { return productService.findWithSkipAndTop(skip, top); } @GetMapping("/jooq/withPage") @ResponseStatus(HttpStatus.OK) public Page findAllJooq( @RequestParam(name = "page") int page, @RequestParam(name = "pageSize") int size) { return productServiceJooq.findWithPageable(page, size); } @GetMapping("/jooq/withSkipAndTop") @ResponseStatus(HttpStatus.OK) public Iterable findWithSkipAndTopJooq( @RequestParam(name = "skip") int skip, @RequestParam(name = "top") int top) { return productServiceJooq.findWithSkipAndTop(skip, top); } } |
اینجا دو کلاس سرویس داریم: یکی برای JPA و دیگری برای JOOQ.
کاربران جاوا پرو که علاقهمند به یادگیری Spring Boot هستند، در دورههای زیر ثبتنام کردهاند:
Paging با JPA
خود JPA بهصورت داخلی از صفحهبندی پشتیبانی میکند، بنابراین پیادهسازی آن خیلی ساده است. کافی است در مخزن (Repository) متدی تعریف کنید که یک پارامتر از نوع Pageable بگیرد و یک Page برگرداند.
در ادامه، کلاس سرویس متد مخزن را صدا میزند و از یک PageRequest که با شماره صفحه (page number) و اندازه صفحه (page size) ساخته شده، استفاده میکند.
| @Repository public interface ProductRepository extends CrudRepository { Page findAll(Pageable pageable); }@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; public Page findByPage(int page, int size) { return productRepository.findAll(PageRequest.of(page, size)); }} |
پاسخ به این شکل خواهد بود:
| { "content": [ { "id": 26, "name": "Product 1", "description": "Description for Product 1", "price": 10.99, "image": "image1.jpg", ... }, { "id": 27, "name": "Product 2", "description": "Description for Product 2", "price": 15.49, "image": "image2.jpg", ... }, ... ], "pageable": { "pageNumber": 0, "pageSize": 10, "sort": { "empty": true, "sorted": false, "unsorted": true }, "offset": 0, "unpaged": false, "paged": true }, "last": false, "totalPages": 3, "totalElements": 25, "first": true, "size": 10, "number": 0, "sort": { "empty": true, "sorted": false, "unsorted": true }, "numberOfElements": 10, "empty": false } |
Skip و Top با JPA
خود JPA بهصورت پیشفرض از skip و top پشتیبانی نمیکند، بنابراین برای رسیدن به این قابلیت باید از راهکارهای جایگزین استفاده کنیم. یکی از گزینهها این است که شماره صفحه و اندازه صفحه را بر اساس مقادیر skip و top محاسبه کنیم؛ هرچند این روش همیشه بهطور قابل اعتماد جواب نمیدهد. راهحل بهتر این است که یک Pageable سفارشی پیادهسازی کنیم و با بازنویسی متدهای لازم، رفتار درست را ایجاد کنیم.
| @RequiredArgsConstructor(staticName = "of") public class PageableWithSkipAndTop implements Pageable { private final int skip; private final int top; private final Sort sort = Sort.unsorted(); @Override public int getPageNumber() { return 0; } @Override public int getPageSize() { return top; } @Override public long getOffset() { return skip; } @Override public Sort getSort() { return sort; } @Override public Pageable next() { return this; } @Override public Pageable previousOrFirst() { return this; } @Override public Pageable first() { return this; } @Override public Pageable withPage(int pageNumber) { return this; } @Override public boolean hasPrevious() { return false; } } |
با این کار، کلاس سرویس تا حد امکان ساده باقی میماند — فقط یک نمونه از PageableWithSkipAndTop میسازد و یک لیست از Products برمیگرداند.
| @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; public List findWithSkipAndTop(int skip, int top) { return productRepository.findAll(PageableWithSkipAndTop.of(skip, top)).getContent(); } } |
JOOQ با Skip و Top
از طرف دیگر، خود JOOQ بهصورت داخلی از skip و top پشتیبانی میکند. فقط کافی است این عبارتها را مستقیماً داخل کوئری بهکار ببرید، همانطور که در پیادهسازی مخزن (Repository) نشان داده شده است.
| @Repository @RequiredArgsConstructor public class ProductRepositoryJooq { private final DSLContext dslContext; public List findWithSkipAndTop(int skip, int top) { return dslContext .select() .from(ProductTable.PRODUCT) .offset(skip) .limit(top) .fetchinto(ProductRecord.class); } } |
مخزن (Repository) از یک کلاس ProductTable استفاده میکند که تعریف جدول در JOOQ را در خود دارد و همچنین یک ProductRecord DTO برای نگهداری نتایج کوئری.
کلاس سرویس هم خیلی ساده است:
| @Service @RequiredArgsConstructor public class ProductServiceJooq { private final ProductRepositoryJooq productRepositoryJooq; public List findWithSkipAndTop(int skip, int top) { return productRepositoryJooq.findWithSkipAndTop(skip, top); } } |
JOOQ با Pagination
اینجا کمی قضیه پیچیدهتر میشود. از آنجایی که JOOQ بهصورت پیشفرض مثل JPA از صفحهبندی پشتیبانی نمیکند، باید از limit و OFFSET برای گرفتن رکوردهای درست استفاده کنید. علاوه بر این، نیاز است دو کوئری اجرا شود: یکی برای گرفتن تعداد کل رکوردها و دیگری برای دریافت صفحهی واقعی نتایج.
| @Repository @RequiredArgsConstructor public class ProductRepositoryJooq { private final DSLContext dslContext; public Page findWithPageable(int page, int size) { Pageable pageable = PageRequest.of(page, size); List records = dslContext .selectfrom(ProductTable.PRODUCT) .limit(pageable.getPageSize()) .offset((int) pageable.getOffset()) .fetchinto(ProductRecord.class); int total = dslContext.fetchCount(DSL.select().from(ProductTable.PRODUCT)); return new PageImpl(records, pageable, total); }} |
خلاصه
برای پیادهسازی صفحهبندی میتوانید از JPA یا JOOQ استفاده کنید، اما چند تفاوت مهم وجود دارد:
اگر قصد دارید از صفحهبندی مبتنی بر شماره صفحه استفاده کنید، JPA گزینهی بهتری است.
اگر نیاز به پیادهسازی skip و top دارید، JOOQ مناسبتر است.
منبع: نوشته شده توسط Renan Schmitt

