← Назад к вопросам

Entity Framework: Реализация пагинации

2.0 Middle🔥 151 комментариев
#Entity Framework и ORM

Условие

В приложении ASP.NET Core Web API необходимо реализовать пагинацию для списка продуктов.

Дано:

  • DbContext с DbSet<Product> Products
  • Product содержит: Id, Name, Price, CreatedAt

Требования:

  1. Реализовать метод получения страницы продуктов с сортировкой
  2. Метод должен принимать: pageNumber, pageSize, sortBy, sortDirection
  3. Возвращать DTO с данными и метаинформацией о пагинации

class PagedResult<T> { public List<T> Items { get; set; } public int TotalCount { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } public int TotalPages { get; set; } }

Критерии оценки:

  • Правильное использование Skip и Take
  • Сортировка перед пагинацией
  • Оптимизация запросов (один запрос для данных, один для count)

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

Анализ задачи

Пагинация — это критическая операция в Web API. Ключевые требования:

  • Эффективность — не загружать все данные в память
  • Skip/Take — правильное их применение
  • Сортировка ПЕРЕД пагинацией — обязательно
  • Минимум запросов — один для данных, один для общего количества
  • Валидация — pageNumber > 0, pageSize > 0

Структуры данных

// DTO для результата пагинации
public class PagedResult<T>
{
    public List<T> Items { get; set; }
    public int TotalCount { get; set; }
    public int PageNumber { get; set; }
    public int PageSize { get; set; }
    public int TotalPages { get; set; }
    public bool HasPreviousPage => PageNumber > 1;
    public bool HasNextPage => PageNumber < TotalPages;
}

// DTO для продукта
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public DateTime CreatedAt { get; set; }
}

// Запрос к API
public class PaginationParams
{
    public int PageNumber { get; set; } = 1;
    public int PageSize { get; set; } = 10;
    public string SortBy { get; set; } = "Id";  // По умолчанию
    public string SortDirection { get; set; } = "asc";  // asc или desc
}

Реализация сервиса

public interface IProductService
{
    Task<PagedResult<ProductDto>> GetProductsAsync(
        int pageNumber,
        int pageSize,
        string sortBy,
        string sortDirection);
}

public class ProductService : IProductService
{
    private readonly ApplicationDbContext _context;
    private readonly IMapper _mapper;  // AutoMapper

    public ProductService(ApplicationDbContext context, IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }

    public async Task<PagedResult<ProductDto>> GetProductsAsync(
        int pageNumber,
        int pageSize,
        string sortBy,
        string sortDirection)
    {
        // 1. Валидация параметров
        if (pageNumber < 1)
            pageNumber = 1;
        if (pageSize < 1 || pageSize > 100)  // Максимум 100 на страницу
            pageSize = 10;

        // 2. Создаём базовый query
        var query = _context.Products.AsQueryable();

        // 3. Применяем сортировку ДО Skip/Take
        query = ApplySorting(query, sortBy, sortDirection);

        // 4. Получаем общее количество (ВАЖНО: до Skip/Take)
        var totalCount = await query.CountAsync();

        // 5. Применяем пагинацию
        var items = await query
            .Skip((pageNumber - 1) * pageSize)  // Пропускаем элементы
            .Take(pageSize)                       // Берём нужное количество
            .ToListAsync();                       // Выполняем запрос

        // 6. Преобразуем в DTO
        var itemDtos = _mapper.Map<List<ProductDto>>(items);

        // 7. Возвращаем результат
        return new PagedResult<ProductDto>
        {
            Items = itemDtos,
            TotalCount = totalCount,
            PageNumber = pageNumber,
            PageSize = pageSize,
            TotalPages = (totalCount + pageSize - 1) / pageSize  // Округление вверх
        };
    }

    // Вспомогательный метод для динамической сортировки
    private IQueryable<Product> ApplySorting(
        IQueryable<Product> query,
        string sortBy,
        string sortDirection)
    {
        // Безопасная сортировка (защита от SQL injection)
        var allowedSortFields = new[] { "Id", "Name", "Price", "CreatedAt" };
        
        if (string.IsNullOrEmpty(sortBy) || !allowedSortFields.Contains(sortBy))
            sortBy = "Id";

        var isDescending = sortDirection?.ToLower() == "desc";

        // Применяем сортировку
        return sortBy switch
        {
            "Name" => isDescending 
                ? query.OrderByDescending(p => p.Name)
                : query.OrderBy(p => p.Name),
                
            "Price" => isDescending
                ? query.OrderByDescending(p => p.Price)
                : query.OrderBy(p => p.Price),
                
            "CreatedAt" => isDescending
                ? query.OrderByDescending(p => p.CreatedAt)
                : query.OrderBy(p => p.CreatedAt),
                
            _ => isDescending  // Id по умолчанию
                ? query.OrderByDescending(p => p.Id)
                : query.OrderBy(p => p.Id)
        };
    }
}

Реализация контроллера

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet]
    public async Task<ActionResult<PagedResult<ProductDto>>> GetProducts(
        [FromQuery] int pageNumber = 1,
        [FromQuery] int pageSize = 10,
        [FromQuery] string sortBy = "Id",
        [FromQuery] string sortDirection = "asc")
    {
        var result = await _productService.GetProductsAsync(
            pageNumber,
            pageSize,
            sortBy,
            sortDirection);

        return Ok(result);
    }
}

Использование API:

GET /api/products?pageNumber=1&pageSize=10&sortBy=Price&sortDirection=desc

Альтернатива: Reflection-based сортировка (более гибкая)

private IQueryable<Product> ApplySortingDynamic(
    IQueryable<Product> query,
    string sortBy,
    string sortDirection)
{
    if (string.IsNullOrEmpty(sortBy))
        return query;

    // Проверяем, существует ли свойство
    var property = typeof(Product).GetProperty(
        sortBy,
        System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public);

    if (property == null)
        return query;

    // Создаём expression: p => p.Property
    var parameter = Expression.Parameter(typeof(Product), "p");
    var propertyAccess = Expression.Property(parameter, property);
    var orderByExpression = Expression.Lambda(propertyAccess, parameter);

    // Применяем сортировку
    var methodName = sortDirection?.ToLower() == "desc" 
        ? "OrderByDescending" 
        : "OrderBy";

    var resultExpression = Expression.Call(
        typeof(Queryable),
        methodName,
        new[] { typeof(Product), property.PropertyType },
        query.Expression,
        Expression.Quote(orderByExpression));

    return query.Provider.CreateQuery<Product>(resultExpression);
}

Оптимизация: Раздельные запросы

❌ Неоптимально — два отдельных запроса:

var totalCount = await _context.Products.CountAsync();  // Запрос 1
var items = await _context.Products
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();  // Запрос 2

✅ Оптимально — один запрос для count, один для данных:

var query = _context.Products.AsQueryable();
query = ApplySorting(query, sortBy, sortDirection);

// Запрос 1: получаем count (выполняется параллельно)
var countTask = query.CountAsync();

// Запрос 2: получаем данные (выполняется параллельно)
var itemsTask = query
    .Skip((pageNumber - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

await Task.WhenAll(countTask, itemsTask);

var totalCount = countTask.Result;
var items = itemsTask.Result;

Продвинутая оптимизация: Параллельные запросы

public async Task<PagedResult<ProductDto>> GetProductsOptimizedAsync(
    int pageNumber,
    int pageSize,
    string sortBy,
    string sortDirection)
{
    if (pageNumber < 1) pageNumber = 1;
    if (pageSize < 1 || pageSize > 100) pageSize = 10;

    var query = _context.Products.AsQueryable();
    query = ApplySorting(query, sortBy, sortDirection);

    // Выполняем оба запроса параллельно
    var countTask = query.CountAsync();
    var itemsTask = query
        .Skip((pageNumber - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();

    await Task.WhenAll(countTask, itemsTask);

    var totalCount = countTask.Result;
    var items = itemsTask.Result;
    var itemDtos = _mapper.Map<List<ProductDto>>(items);

    return new PagedResult<ProductDto>
    {
        Items = itemDtos,
        TotalCount = totalCount,
        PageNumber = pageNumber,
        PageSize = pageSize,
        TotalPages = (totalCount + pageSize - 1) / pageSize
    };
}

Обработка ошибок

public async Task<PagedResult<ProductDto>> GetProductsAsync(
    int pageNumber,
    int pageSize,
    string sortBy,
    string sortDirection)
{
    try
    {
        // Валидация
        if (pageNumber < 1 || pageSize < 1)
            throw new ArgumentException("Page number and size must be > 0");

        if (pageSize > 100)
            throw new ArgumentException("Page size cannot exceed 100");

        var query = _context.Products.AsQueryable();
        query = ApplySorting(query, sortBy, sortDirection);

        var totalCount = await query.CountAsync();
        var items = await query
            .Skip((pageNumber - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync();

        if (pageNumber > (totalCount + pageSize - 1) / pageSize && totalCount > 0)
            throw new ArgumentException($"Page {pageNumber} exceeds total pages");

        return new PagedResult<ProductDto>
        {
            Items = _mapper.Map<List<ProductDto>>(items),
            TotalCount = totalCount,
            PageNumber = pageNumber,
            PageSize = pageSize,
            TotalPages = (totalCount + pageSize - 1) / pageSize
        };
    }
    catch (Exception ex)
    {
        // Логируем ошибку
        _logger.LogError(ex, "Error getting products");
        throw;  // Пробрасываем вверх для обработки middleware
    }
}

Кэширование результатов (для часто запрашиваемых данных)

public async Task<PagedResult<ProductDto>> GetProductsCachedAsync(
    int pageNumber,
    int pageSize,
    string sortBy,
    string sortDirection)
{
    var cacheKey = $"products_{pageNumber}_{pageSize}_{sortBy}_{sortDirection}";

    if (_cache.TryGetValue(cacheKey, out PagedResult<ProductDto> cachedResult))
        return cachedResult;

    var result = await GetProductsAsync(pageNumber, pageSize, sortBy, sortDirection);

    // Кэшируем на 5 минут
    var cacheOptions = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(TimeSpan.FromMinutes(5));

    _cache.Set(cacheKey, result, cacheOptions);

    return result;
}

Правильный SQL запрос (что генерирует EF Core)

-- Запрос за данными:
SELECT p.Id, p.Name, p.Price, p.CreatedAt
FROM Products p
ORDER BY p.Price DESC
OFFSET 10 ROWS
FETCH NEXT 10 ROWS ONLY;

-- Запрос за count:
SELECT COUNT(*)
FROM Products;

Это эффективно! OFFSET и FETCH работают на уровне БД.


Пример ответа API

{
  "items": [
    {
      "id": 1,
      "name": "Laptop",
      "price": 999.99,
      "createdAt": "2024-01-15T10:30:00Z"
    },
    {
      "id": 2,
      "name": "Mouse",
      "price": 29.99,
      "createdAt": "2024-01-20T14:22:00Z"
    }
  ],
  "totalCount": 250,
  "pageNumber": 1,
  "pageSize": 10,
  "totalPages": 25,
  "hasPreviousPage": false,
  "hasNextPage": true
}

Выводы

Правильная пагинация:

  1. Валидируйте параметры (pageNumber > 0, pageSize <= max)
  2. Применяйте сортировку ПЕРЕД Skip/Take
  3. Используйте CountAsync() до Skip/Take
  4. Используйте параллельные запросы когда возможно
  5. Ограничивайте максимальный pageSize (например, 100)
  6. Кэшируйте часто запрашиваемые страницы
  7. Логируйте ошибки валидации
  8. Возвращайте метаинформацию (TotalPages, HasNextPage)
Entity Framework: Реализация пагинации | PrepBro