← Назад к вопросам
Entity Framework: Реализация пагинации
2.0 Middle🔥 151 комментариев
#Entity Framework и ORM
Условие
В приложении ASP.NET Core Web API необходимо реализовать пагинацию для списка продуктов.
Дано:
- DbContext с DbSet<Product> Products
- Product содержит: Id, Name, Price, CreatedAt
Требования:
- Реализовать метод получения страницы продуктов с сортировкой
- Метод должен принимать: pageNumber, pageSize, sortBy, sortDirection
- Возвращать 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
}
Выводы
✅ Правильная пагинация:
- Валидируйте параметры (pageNumber > 0, pageSize <= max)
- Применяйте сортировку ПЕРЕД Skip/Take
- Используйте CountAsync() до Skip/Take
- Используйте параллельные запросы когда возможно
- Ограничивайте максимальный pageSize (например, 100)
- Кэшируйте часто запрашиваемые страницы
- Логируйте ошибки валидации
- Возвращайте метаинформацию (TotalPages, HasNextPage)