Пошук уроків, статей та іншого контенту
Реалізуєте сторінкову видачу ресурсів із параметрами розміру сторінки, зміщення та метаданими.
Пагінація — це поділ великої колекції ресурсів на частини. Замість того щоб повертати всі записи одним HTTP-відповідім, API повертає лише потрібний фрагмент.
Для offset-пагінації використовують два параметри:
size — кількість елементів на сторінці;
offset — кількість елементів, які потрібно пропустити перед вибіркою.
Наприклад:
GET /products?size=10&offset=20Такий запит означає:
пропустити перші 20 товарів;
повернути наступні 10 товарів.
Разом із даними API зазвичай повертає метадані:
загальну кількість ресурсів;
поточний розмір сторінки;
поточне зміщення;
кількість повернутих елементів;
ознаку наявності наступної та попередньої сторінки.
Параметри з query string надходять у застосунок як рядки. Наприклад, значення size=10 спочатку буде рядком "10".
Створимо DTO, який:
перетворює рядки на числа;
задає значення за замовчуванням;
перевіряє допустимі значення;
обмежує максимальний розмір сторінки.
// pagination.dto.ts
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
size = 20;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset = 0;
}У цьому прикладі:
size має бути цілим числом від 1 до 100;
offset має бути цілим невід'ємним числом;
якщо параметри не передані, використовуються size = 20 і offset = 0.
Щоб трансформація типів і валідація працювали, у застосунку потрібно увімкнути глобальний ValidationPipe:
// main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
);
await app.listen(3000);
}
bootstrap();Опція transform: true дозволяє перетворювати query-параметри відповідно до типів DTO.
Опція whitelist: true видаляє з об'єкта властивості, яких немає в DTO.
Сервіс отримує size та offset, вибирає потрібний фрагмент колекції та формує метадані.
// products.service.ts
import { Injectable } from '@nestjs/common';
import { PaginationDto } from './pagination.dto';
interface Product {
id: number;
name: string;
price: number;
}
@Injectable()
export class ProductsService {
private readonly products: Product[] = Array.from(
{ length: 57 },
(_, index) => ({
id: index + 1,
name: `Product ${index + 1}`,
price: (index + 1) * 10,
}),
);
findAll(pagination: PaginationDto) {
const { size, offset } = pagination;
const total = this.products.length;
const items = this.products.slice(offset, offset + size);
return {
data: items,
meta: {
total,
size,
offset,
returned: items.length,
hasNext: offset + items.length < total,
hasPrevious: offset > 0,
},
};
}
}Метод slice(start, end) не змінює початковий масив і повертає його частину:
this.products.slice(offset, offset + size);Наприклад, для offset = 20 і size = 10 діапазон буде таким:
slice(20, 30)Якщо в колекції залишилося менше елементів, slice поверне лише доступні елементи. Це дозволяє коректно обробити останню сторінку.
У контролері параметри отримуються через декоратор @Query().
// products.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { PaginationDto } from './pagination.dto';
import { ProductsService } from './products.service';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll(@Query() pagination: PaginationDto) {
return this.productsService.findAll(pagination);
}
}Тепер endpoint підтримує такі запити:
GET /products
GET /products?size=5
GET /products?size=5&offset=10Для запиту:
GET /products?size=5&offset=10відповідь матиме приблизно такий вигляд:
{
"data": [
{
"id": 11,
"name": "Product 11",
"price": 110
},
{
"id": 12,
"name": "Product 12",
"price": 120
},
{
"id": 13,
"name": "Product 13",
"price": 130
},
{
"id": 14,
"name": "Product 14",
"price": 140
},
{
"id": 15,
"name": "Product 15",
"price": 150
}
],
"meta": {
"total": 57,
"size": 5,
"offset": 10,
"returned": 5,
"hasNext": true,
"hasPrevious": true
}
}Для повного прикладу контролер і сервіс потрібно додати до модуля:
// products.module.ts
import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
@Module({
controllers: [ProductsController],
providers: [ProductsService],
})
export class ProductsModule {}А модуль ресурсів — до кореневого модуля:
// app.module.ts
import { Module } from '@nestjs/common';
import { ProductsModule } from './products/products.module';
@Module({
imports: [ProductsModule],
})
export class AppModule {}Для запуску прикладу в новому NestJS-проєкті потрібні пакети class-validator і class-transformer:
npm install class-validator class-transformerПісля запуску застосунку запит:
curl "http://localhost:3000/products?size=5&offset=10"поверне п'ять товарів, починаючи з одинадцятого.
Завдяки ValidationPipe некоректні значення автоматично відхиляються.
Наприклад, цей запит:
GET /products?size=0поверне помилку валідації, оскільки мінімальне значення size дорівнює 1.
Так само будуть відхилені:
GET /products?size=101
GET /products?size=-5
GET /products?offset=-1
GET /products?size=abcОбмеження максимальної кількості елементів важливе для захисту API. Без нього клієнт міг би запитати тисячі або мільйони записів одним запитом.
У прикладі використовується масив, але принцип для бази даних такий самий:
отримати загальну кількість записів;
отримати потрібну частину записів;
об'єднати результати в одну відповідь.
Для SQL-запитів зазвичай використовують LIMIT і OFFSET:
SELECT *
FROM products
ORDER BY id
LIMIT 20 OFFSET 40;Критерій сортування важливий. Без ORDER BY порядок записів не гарантований, тому різні сторінки можуть містити дублікати або пропуски.
На рівні ORM відповідні операції зазвичай називаються take і skip, або мають схожі назви. Конкретний синтаксис залежить від ORM, але значення залишаються такими самими:
take — скільки записів отримати;
skip — скільки записів пропустити.
Під час отримання метаданих total потрібно виконати окремий підрахунок або використати можливість ORM повернути кількість разом із записами.
Рекомендується повертати колекцію в об'єкті з двома основними властивостями:
{
"data": [],
"meta": {}
}Переваги такого формату:
клієнт одразу розуміє, де знаходяться ресурси;
метадані не змішуються з полями самих ресурсів;
формат легко розширити додатковими властивостями;
всі endpoint-и колекцій можуть використовувати однакову структуру.
Мінімальний набір метаданих:
{
"total": 57,
"size": 20,
"offset": 40,
"returned": 17,
"hasNext": false,
"hasPrevious": true
}returned корисний на останній сторінці, де кількість елементів може бути меншою за size.
Якщо база даних повертає записи без стабільного сортування, результат між запитами може змінюватися.
Потрібно явно вказувати порядок, наприклад за id:
ORDER BY id ASCsizeНе варто дозволяти клієнту передавати необмежений розмір сторінки. Використовуйте @Max() у DTO.
Query-параметри надходять як рядки. Якщо не використати @Type(() => Number) і transform: true, перевірка та арифметичні операції можуть працювати неочікувано.
hasNextПеревірка має враховувати фактичну кількість повернутих елементів:
hasNext: offset + items.length < totalПорівняння лише offset + size < total може бути менш точним для останньої сторінки або порожнього результату.
offsetДля дуже великих колекцій offset-пагінація може ставати повільною, оскільки база даних повинна пропустити багато записів. Для цього існують інші стратегії пагінації, але в межах цього підходу важливо принаймні обмежувати size і використовувати стабільне сортування.
Пагінація повертає лише частину колекції замість усіх ресурсів.
size визначає кількість елементів на сторінці.
offset визначає кількість пропущених елементів.
DTO з class-validator перевіряє параметри запиту.
class-transformer перетворює query-параметри з рядків на числа.
Відповідь варто повертати у форматі data та meta.
Метадані мають містити загальну кількість, параметри запиту та інформацію про сусідні сторінки.
Для стабільної пагінації в базі даних потрібно використовувати явне сортування.