Пагинация в ASP.NET Core Web Api

Что такое пагинация?

Под разбивкой по страницам понимается получение частичных результатов от API. Представьте, что в базе данных есть миллионы результатов и ваше приложение пытается вернуть их все сразу.

Это не только было бы крайне неэффективным способом возврата результатов, но также могло бы иметь разрушительные последствия для самого приложения или оборудования, на котором оно работает. Более того, каждый клиент имеет ограниченные ресурсы памяти, и ему необходимо ограничить количество отображаемых результатов.

Таким образом, нам нужен способ вернуть клиенту заданное количество результатов, чтобы избежать этих последствий.

Посмотрим, как мы можем это сделать.

Начальная реализация

Прежде чем вносить какие-либо изменения в исходный код, давайте посмотрим, как он выглядит прямо сейчас и как вы, вероятно, начнете с любым проектом.

В нашем случае у нас есть OwnerController, который выполняет все необходимые действия с объектом Owner.

Особое внимание следует обратить на действие GetOwners(), которое необходимо изменить:

[HttpGet]
public IActionResult GetOwners()
{
	var owners = _repository.Owner.GetAllOwners();

	_logger.LogInfo($"Returned all owners from database.");

	return Ok(owners);
}

Который вызывает GetOwners() с OwnerRepository:

public IEnumerable<Owner> GetOwners()
{
	return FindAll()
		.OrderBy(ow => ow.Name);
}

Метод FindAll() - это просто метод из класса базового репозитория, который возвращает весь набор владельцев.

public IQueryable<T> FindAll()
{
	return this.RepositoryContext.Set<T>();
}

Как видите, это простое действие, предназначенное для возврата всех владельцев из базы данных, упорядоченных по именам.

Но в нашем случае это всего несколько владельцев аккаунтов (пять). Что, если бы в базе данных были тысячи или даже миллионы людей (вы хотите, но все же представьте себе другой вид сущности). Конец, затем добавьте к этому несколько тысяч потребителей API.

В итоге мы получим очень длинный запрос, который возвращает МНОГО данных.

В лучшем случае вы начинаете с небольшого числа владельцев, которое со временем медленно увеличивалось, чтобы вы могли заметить медленное снижение производительности. Другие сценарии гораздо менее благоприятны для вашего приложения и машин (представьте, что они размещены в облаке и не имеют надлежащего кэширования).

Имея это в виду, давайте изменим этот метод для поддержки разбиения на страницы.

Реализация пагинации

Имейте в виду, мы не хотим изменять логику базового репозитория или реализовывать какую-либо бизнес-логику в контроллере.

Мы хотим получить примерно следующее: https://localhost:5001/api/owners?pageNumber=2&pageSize=2. Это должно вернуть второй набор из двух владельцев, который есть в нашей базе данных.

Мы также хотим ограничить наш API, чтобы он не возвращал всех владельцев, даже если кто-то позвонит https://localhost:5001/api/owners.

Начнем с изменения контроллера:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	var owners = _repository.Owner.GetOwners(ownerParameters);

	_logger.LogInfo($"Returned {owners.Count()} owners from database.");

	return Ok(owners);
}

Здесь следует отметить несколько моментов:

  • Мы вызываем метод GetOwners из OwnerRepository, которого еще не существует, но мы скоро его реализуем.
  • Мы используем [FromQuery], чтобы указать, что мы будем использовать параметры запроса, чтобы определить, какую страницу и сколько владельцев мы запрашиваем.
  • Класс OwnerParameters - это контейнер для фактических параметров.

Нам также необходимо создать класс OwnerParameters, поскольку мы передаем его в качестве аргумента нашему контроллеру. Создадим его в папке Models проекта Entities:

public class OwnerParameters
{
	const int maxPageSize = 50;
	public int PageNumber { get; set; } = 1;

	private int _pageSize = 10;
	public int PageSize
	{
		get
		{
			return _pageSize;
		}
		set
		{
			_pageSize = (value > maxPageSize) ? maxPageSize : value;
		}
	}
}

Мы используем константу maxPageSize, чтобы ограничить наш API до 50 владельцев. У нас есть два общедоступных свойства - PageNumber и PageSize. Если вызывающая сторона не устанавливает, PageNumber будет иметь значение 1, а PageSize - 10.

Теперь давайте реализуем самую важную часть - логику репозитория.

Нам нужно расширить метод GetOwners() в интерфейсе IOwnerRepository и в классе OwnerRepository:

public interface IOwnerRepository : IRepositoryBase<Owner>
{
	IEnumerable<Owner> GetOwners(OwnerParameters ownerParameters);
	Owner GetOwnerById(Guid ownerId);
	OwnerExtended GetOwnerWithDetails(Guid ownerId);
	void CreateOwner(Owner owner);
	void UpdateOwner(Owner dbOwner, Owner owner);
	void DeleteOwner(Owner owner);
}

И логику:

public IEnumerable<Owner> GetOwners(OwnerParameters ownerParameters)
{
	return FindAll()
		.OrderBy(on => on.Name)
		.Skip((ownerParameters.PageNumber - 1) * ownerParameters.PageSize)
		.Take(ownerParameters.PageSize)
		.ToList();
}

Хорошо, проще всего объяснить это на примере.

Допустим, нам нужно получить результаты для третьей страницы нашего веб-сайта, считая 20 как количество результатов, которые мы хотим. Это означало бы, что мы хотим пропустить первые ((3 - 1) * 20) = 40 результатов, а затем взять следующие 20 и вернуть их вызывающему.

Есть ли в этом смысл?

Тестирование решения

Теперь в нашей базе данных всего несколько владельцев, поэтому давайте попробуем что-то вроде этого:

https://localhost:5001/api/owners?pageNumber=2&pageSize=2

Это должно вернуть следующее подмножество владельцев:

[
    {
        "id": "66774006-2371-4d5b-8518-2177bcf3f73e",
        "name": "Nick Somion",
        "dateOfBirth": "1998-12-15T00:00:00",
        "address": "North sunny address 102"
    },
    {
        "id": "a3c1880c-674c-4d18-8f91-5d3608a2c937",
        "name": "Sam Query",
        "dateOfBirth": "1990-04-22T00:00:00",
        "address": "91 Western Roads"
    }
]

Если это то, что у вас есть, вы на правильном пути.

Что мы можем сделать, чтобы улучшить это решение?

Улучшаем нашу пагинацию

Поскольку мы возвращаем вызывающему только подмножество результатов, мы могли бы получить PagedList вместо List.

PagedList унаследует от класса List и добавит к нему еще несколько. Мы также можем переместить логику пропуска/приема в PagedList, поскольку это имеет больше смысла.

Давайте реализуем это.

Реализация класса PagedList

Мы не хотим, чтобы наша логика пропуска/приема была реализована внутри нашего репозитория. Создадим для него класс:

public class PagedList<T> : List<T>
{
	public int CurrentPage { get; private set; }
	public int TotalPages { get; private set; }
	public int PageSize { get; private set; }
	public int TotalCount { get; private set; }

	public bool HasPrevious => CurrentPage > 1;
	public bool HasNext => CurrentPage < TotalPages;

	public PagedList(List<T> items, int count, int pageNumber, int pageSize)
	{
		TotalCount = count;
		PageSize = pageSize;
		CurrentPage = pageNumber;
		TotalPages = (int)Math.Ceiling(count / (double)pageSize);

		AddRange(items);
	}

	public static PagedList<T> ToPagedList(IQueryable<T> source, int pageNumber, int pageSize)
	{
		var count = source.Count();
		var items = source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();

		return new PagedList<T>(items, count, pageNumber, pageSize);
	}
}

Как видите, мы перенесли логику пропуска/приема в статический метод внутри класса PagedList. Мы добавили еще несколько свойств, которые пригодятся в качестве метаданных для нашего ответа.

HasPrevious истинно, если CurrentPage больше 1, и HasNext вычисляется, если CurrentPage меньше общего количества страниц. TotalPages также вычисляется путем деления количества элементов на размер страницы и последующего округления до большего числа, поскольку страница должна существовать, даже если на ней есть один элемент.

Теперь, когда мы это прояснили, давайте изменим OwnerRepository и OwnerController соответственно.

Во-первых, нам нужно изменить репо (не забудьте также изменить интерфейс):

public PagedList<Owner> GetOwners(OwnerParameters ownerParameters)
{
	return PagedList<Owner>.ToPagedList(FindAll().OrderBy(on => on.Name),
		ownerParameters.PageNumber,
		ownerParameters.PageSize);
}

А затем контроллер:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	var owners = _repository.Owner.GetOwners(ownerParameters);

	var metadata = new
	{
		owners.TotalCount,
		owners.PageSize,
		owners.CurrentPage,
		owners.TotalPages,
		owners.HasNext,
		owners.HasPrevious
	};

	Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata));

	_logger.LogInfo($"Returned {owners.TotalCount} owners from database.");

	return Ok(owners);
}

Теперь, если мы отправим тот же запрос, что и раньше https://localhost:5001/api/owners?pageNumber=2&pageSize=2, мы получим тот же точный результат:

[
    {
        "id": "f98e4d74-0f68-4aac-89fd-047f1aaca6b6",
        "name": "Martin Miller",
        "dateOfBirth": "1983-05-21T00:00:00",
        "address": "3 Edgar Buildings"
    },
    {
        "id": "66774006-2371-4d5b-8518-2177bcf3f73e",
        "name": "Nick Somion",
        "dateOfBirth": "1998-12-15T00:00:00",
        "address": "North sunny address 102"
    }
]

Но теперь у нас есть дополнительная полезная информация в заголовке ответа X-Pagination:

Как видите, все наши метаданные здесь. Мы можем использовать эту информацию при построении любого вида функциональности пагинации в интерфейсе. Вы можете поиграть с разными запросами, чтобы увидеть, как это работает в других сценариях.

Есть еще одна вещь, которую мы можем сделать, чтобы сделать наше решение еще более универсальным. У нас есть класс OwnerParameters, но что, если мы захотим использовать его в нашем AccountController? Параметры, которые мы отправляем контроллеру учетной записи, могут отличаться. Возможно, не для разбиения по страницам, но мы отправим кучу разных параметров позже, и нам нужно разделить классы параметров.

Посмотрим, как это улучшить.

Создание родительского класса для параметров

Сначала создадим абстрактный класс QueryStringParameters. Мы будем использовать этот класс для реализации совместно используемых функций для каждого класса параметров, который мы будем реализовывать. А поскольку у нас есть OwnerController и AccountController, это означает, что нам нужно создать классы OwnerParameters и AccountParameters.

Начнем с определения класса QueryStringParameters в папке Models проекта Entities:

public abstract class QueryStringParameters
{
	const int maxPageSize = 50;
	public int PageNumber { get; set; } = 1;

	private int _pageSize = 10;
	public int PageSize
	{
		get
		{
			return _pageSize;
		}
		set
		{
			_pageSize = (value > maxPageSize) ? maxPageSize : value;
		}
	}
}

Мы также переместили нашу логику разбиения на страницы внутри класса, поскольку она будет действительна для любой сущности, которую мы, возможно, захотим вернуть через репозиторий.

Теперь нам нужно создать класс AccountParameters, а затем унаследовать класс QueryStringParameters от классов OwnerParameters и AccountParameters.

Удалите логику из OwnerParameters и унаследуйте QueryStringParameters:

public class OwnerParameters : QueryStringParameters
{
		
}

И создайте класс AccountParameters внутри папки Models:

public class AccountParameters : QueryStringParameters
{
		
}

Сейчас эти классы выглядят немного пустыми, но скоро мы заполним их другими полезными параметрами и увидим, в чем реальная выгода. На данный момент важно, чтобы у нас был способ отправить другой набор параметров для AccountController и OwnerController.

Теперь мы можем сделать что-то подобное внутри нашего AccountController:

[HttpGet]
public IActionResult GetAccountsForOwner(Guid ownerId, [FromQuery] AccountParameters parameters)
{
	var accounts = _repository.Account.GetAccountsByOwner(ownerId, parameters);

	var metadata = new
	{
		accounts.TotalCount,
		accounts.PageSize,
		accounts.CurrentPage,
		accounts.TotalPages,
		accounts.HasNext,
		accounts.HasPrevious
	};

	Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata));

	_logger.LogInfo($"Returned {accounts.TotalCount} owners from database.");

	return Ok(accounts);
}

И за счет наследования параметров загрузки через класс QueryStringParameters мы получаем такое же поведение.

Заключение

Пагинация - полезная и важная концепция при создании любого API. Без него наше приложение, вероятно, значительно замедлилось бы.

Реализованное нами решение далеко не идеально, но вы поняли суть. Мы изолировали различные части механизма загрузки и можем пойти еще дальше и сделать его более общим. Но вы можете сделать это как в статье и реализовать в своем собственном проекте.

В этой статье мы рассмотрели:

  • Самый простой способ реализовать разбиение на страницы в ASP.NET Core Web API.
  • Протестировали решение в реальных условиях.
  • Это решение было улучшено за счет введения объекта PagedList и разделения наших параметров для разных контроллеров.