Как реализовать сортировку в ASP.NET Core Web API

Что такое сортировка?

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

Однако нас интересует, как заставить наш API сортировать результаты так, как мы хотим.

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

Для этого наш вызов API должен выглядеть примерно так:

https://localhost:5001/api/owner?orderBy=name,dateOfBirth desc

Наш API должен учитывать все параметры и соответствующим образом сортировать наши результаты. В нашем случае это означает сортировку результатов по имени, а затем, если есть владельцы с таким же именем, сортировку по свойству DateOfBirth.

Давайте вспомним, как наши данные о владельце выглядят в базе данных:

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

Итак, добавим еще одну Анну Бош:

Другая Анна на 10 лет старше и живет по другому адресу.

Отлично, теперь у нас есть данные, необходимые для правильного тестирования наших функций.

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

Давайте посмотрим, как это реализовать.

Как реализовать сортировку в веб-API ASP.NET Core

Во-первых, поскольку мы хотим, чтобы каждую сущность можно было сортировать по некоторому критерию, мы собираемся добавить свойство OrderBy в наш базовый класс QueryStringParameters:

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;
		}
	}

	public string OrderBy { get; set; }
}

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

Например, мы хотим, чтобы OwnerParameters по умолчанию упорядочивал наши результаты по свойству «имя»:

public class OwnerParameters : QueryStringParameters
{
	public OwnerParameters()
	{
		OrderBy = "name";
	}

	public uint MinYearOfBirth { get; set; }
	public uint MaxYearOfBirth { get; set; } = (uint)DateTime.Now.Year;

	public bool ValidYearRange => MaxYearOfBirth > MinYearOfBirth;

	public string Name { get; set; }
}

И мы хотим, чтобы наши Аккаунты были упорядочены по DateCreated:

public class AccountParameters : QueryStringParameters
{
	public AccountParameters()
	{
		OrderBy = "DateCreated";
	}
}

Затем мы перейдем непосредственно к реализации нашего механизма сортировки, или, скорее, нашего механизма упорядочивания.

Следует отметить, что мы будем использовать System.Linq.Dynamic.Core NuGet для динамического создания нашего запроса OrderBy на лету. Поэтому смело устанавливайте его в проекте Repository и добавляйте директиву using в класс OwnerRepository.

Давайте добавим новый закрытый метод ApplySort в наш класс OwnerRepository:

private void ApplySort(ref IQueryable<Owner> owners, string orderByQueryString)
{
	if (!owners.Any())
		return;

	if (string.IsNullOrWhiteSpace(orderByQueryString))
	{
		owners = owners.OrderBy(x => x.Name);
		return;
	}

	var orderParams = orderByQueryString.Trim().Split(',');
	var propertyInfos = typeof(Owner).GetProperties(BindingFlags.Public | BindingFlags.Instance);
	var orderQueryBuilder = new StringBuilder();

	foreach (var param in orderParams)
	{
		if (string.IsNullOrWhiteSpace(param))
			continue;

		var propertyFromQueryName = param.Split(" ")[0];
		var objectProperty = propertyInfos.FirstOrDefault(pi => pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase));

		if (objectProperty == null)
			continue;

		var sortingOrder = param.EndsWith(" desc") ? "descending" : "ascending";

		orderQueryBuilder.Append($"{objectProperty.Name.ToString()} {sortingOrder}, ");
	}

	var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' ');

	if (string.IsNullOrWhiteSpace(orderQuery))
	{
		owners = owners.OrderBy(x => x.Name);
		return;
	}

	owners = owners.OrderBy(orderQuery);
}

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

Давайте разберем наш метод.

Реализация - шаг за шагом

Во-первых, давайте начнем с определения метода. У него два аргумента: один для списка владельцев IQueryable, а другой - для запроса на сортировку. Если мы отправим такой запрос https://localhost:5001/api/owner?orderBy=name,dateOfBirth desc, наш orderByQueryString будет name,dateOfBirth desc.

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

if (!owners.Any())
	return;

if (string.IsNullOrWhiteSpace(orderByQueryString))
{
	owners = owners.OrderBy(x => x.Name);
	return;
}

Затем мы разделяем строку запроса, чтобы получить отдельные поля:

var orderParams = orderByQueryString.Trim().Split(',');

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

var propertyInfos = typeof(Owner).GetProperties(BindingFlags.Public | BindingFlags.Instance);

Подготовив это, мы можем фактически просмотреть все параметры и проверить их наличие:

if (string.IsNullOrWhiteSpace(param))
	continue;

var propertyFromQueryName = param.Split(" ")[0];
var objectProperty = propertyInfos.FirstOrDefault(pi => pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase));

Если мы не находим такое свойство, мы пропускаем шаг в цикле foreach и переходим к следующему параметру в списке:

if (objectProperty == null)
	continue;

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

var sortingOrder = param.EndsWith(" desc") ? "descending" : "ascending";

Мы используем StringBuilder для построения нашего запроса с каждым циклом:

orderQueryBuilder.Append($"{objectProperty.Name.ToString()} {sortingOrder}, ");

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

var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' ');

if (string.IsNullOrWhiteSpace(orderQuery))
{
	owners = owners.OrderBy(x => x.Name);
	return;
}

Наконец, мы можем отсортировать наш запрос:

owners = owners.OrderBy(orderQuery);

На этом этапе наша переменная orderQuery должна содержать строку «Имя по возрастанию, DateOfBirth по убыванию». Это означает, что он упорядочит наши результаты сначала по Name в порядке возрастания, а затем по DateOfBirth в порядке убывания.

Стандартный запрос LINQ для этого:

owners.OrderBy(x => x.Name).ThenByDescending(o => o.DateOfBirth);

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

Вызов метода

Теперь, когда мы подробно рассмотрели, как работает этот метод, нам нужно просто вызвать его в нашем методе GetOwners:

public PagedList<Owner> GetOwners(OwnerParameters ownerParameters)
{
	var owners = FindByCondition(o => o.DateOfBirth.Year >= ownerParameters.MinYearOfBirth &&
								o.DateOfBirth.Year <= ownerParameters.MaxYearOfBirth);

	SearchByName(ref owners, ownerParameters.Name);

	ApplySort(ref owners, ownerParameters.OrderBy);

	return PagedList<Owner>.ToPagedList(owners,
		ownerParameters.PageNumber,
		ownerParameters.PageSize);
}

Мы предоставляем список владельцев и строку запроса OrderBy.

Что, если мы хотим использовать ApplySort в AccountRepository? Должны ли мы действительно сохранить эту логику в нашем классе репозитория?

Ответ на оба наших вопроса, конечно же, НЕТ!

Давайте посмотрим, как сделать наш метод более универсальным и реализовать его таким образом, чтобы его можно было использовать как в репозиториях Account, так и Owner (или в любом другом репозитории, который может появиться позже).

Улучшаем решение

Есть две основные вещи, которые мы хотим улучшить. Удалите закрытый метод ApplySort из OwnerRepository и сделайте ApplySort универсальным.

Итак, давайте начнем с определения класса SortHelper и интерфейса ISortHelper в папке Helpers проекта Entities.

ISortHelper должен объявить один метод - ApplySort:

public interface ISortHelper<T>
{
	IQueryable<T> ApplySort(IQueryable<T> entities, string orderByQueryString);
}

Как видите, ISortHelper - это общий интерфейс, и его можно применить к любому типу, который мы захотим. Нам нужно предоставить набор сущностей и строку сортировки.

Теперь посмотрим на фактическую реализацию:

public class SortHelper<T> : ISortHelper<T>
{
	public IQueryable<T> ApplySort(IQueryable<T> entities, string orderByQueryString)
	{
		if (!entities.Any())
			return entities;

		if (string.IsNullOrWhiteSpace(orderByQueryString))
		{
			return entities;
		}

		var orderParams = orderByQueryString.OrderBy.Trim().Split(',');
		var propertyInfos = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
		var orderQueryBuilder = new StringBuilder();

		foreach (var param in orderParams)
		{
			if (string.IsNullOrWhiteSpace(param))
				continue;

			var propertyFromQueryName = param.Split(" ")[0];
			var objectProperty = propertyInfos.FirstOrDefault(pi => pi.Name.Equals(propertyFromQueryName, StringComparison.InvariantCultureIgnoreCase));

			if (objectProperty == null)
				continue;

			var sortingOrder = param.EndsWith(" desc") ? "descending" : "ascending";

			orderQueryBuilder.Append($"{objectProperty.Name.ToString()} {sortingOrder}, ");
		}

		var orderQuery = orderQueryBuilder.ToString().TrimEnd(',', ' ');

		return entities.OrderBy(orderQuery);
	}
}

Эта реализация дает нам возможность внедрить этот класс в наши репозитории и вызывать ApplySort везде, где нам это нужно:

private ISortHelper<Owner> _sortHelper;

public OwnerRepository(RepositoryContext repositoryContext, ISortHelper<Owner> sortHelper)
	: base(repositoryContext)
{
	_sortHelper = sortHelper;
}

Нам также необходимо расширить наш RepoWrapper, так как там создаются экземпляры наших классов репозитория:

public class RepositoryWrapper : IRepositoryWrapper
{
	private RepositoryContext _repoContext;
	private IOwnerRepository _owner;
	private IAccountRepository _account;
	private ISortHelper<Owner> _ownerSortHelper;
	private ISortHelper<Account> _accountSortHelper;

	public IOwnerRepository Owner
	{
		get
		{
			if (_owner == null)
			{
				_owner = new OwnerRepository(_repoContext, _ownerSortHelper);
			}

			return _owner;
		}
	}

	public IAccountRepository Account
	{
		get
		{
			if (_account == null)
			{
				_account = new AccountRepository(_repoContext, _accountSortHelper);
			}

			return _account;
		}
	}

	public RepositoryWrapper(RepositoryContext repositoryContext,
		ISortHelper<Owner> ownerSortHelper,
		ISortHelper<Account> accountSortHelper)
	{
		_repoContext = repositoryContext;
		_ownerSortHelper = ownerSortHelper;
		_accountSortHelper = accountSortHelper;
	}

	public void Save()
	{
		_repoContext.SaveChanges();
	}
}

И теперь мы можем вызвать его в нашем методе GetOwners:

public PagedList<Owner> GetOwners(OwnerParameters ownerParameters)
{
	var owners = FindByCondition(o => o.DateOfBirth.Year >= ownerParameters.MinYearOfBirth &&
								o.DateOfBirth.Year <= ownerParameters.MaxYearOfBirth);

	SearchByName(ref owners, ownerParameters.Name);

	var sortedOwners = _sortHelper.ApplySort(owners, ownerParameters.OrderBy);

	return PagedList<Owner>.ToPagedList(sortedOwners,
		ownerParameters.PageNumber,
		ownerParameters.PageSize);
}

То же самое и с AccountRepository. Если у вас возникли проблемы с его реализацией, обратитесь к готовому проекту на GitHub в верхней части статьи.

И поскольку мы использовали инъекцию зависимостей, чтобы внедрить SortHelper, не забудьте прописать его в нашем классе ServiceExtensions:

public static void ConfigureRepositoryWrapper(this IServiceCollection services)
{
	services.AddScoped<ISortHelper<Owner>, SortHelper<Owner>>();
	services.AddScoped<ISortHelper<Account>, SortHelper<Account>>();

	services.AddScoped<IRepositoryWrapper, RepositoryWrapper>();
}

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

Тестирование нашей реализации

А теперь самое интересное, давайте проверим результат всех наших усилий.

Во-первых, давайте опробуем запрос, который мы использовали в качестве примера: https://localhost:5001/api/owner?orderBy=name,dateOfBirth desc

Ответ должен быть таким:

[
    {
        "id": "261e1685-cf26-494c-b17c-3546e65f5620",
        "name": "Anna Bosh",
        "dateOfBirth": "1974-11-14T00:00:00",
        "address": "27 Colored Row"
    },
    {
        "id": "9c362f85-5581-4182-ac96-b7b88a74dda7",
        "name": "Anna Bosh",
        "dateOfBirth": "1964-11-14T00:00:00",
        "address": "24 Crescent Street"
    },
    {
        "id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
        "name": "John Keen",
        "dateOfBirth": "1980-12-05T00:00:00",
        "address": "61 Wellfield Road"
    },
    {
        "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"
    },
    {
        "id": "a3c1880c-674c-4d18-8f91-5d3608a2c937",
        "name": "Sam Query",
        "dateOfBirth": "1990-04-22T00:00:00",
        "address": "91 Western Roads"
    }
]

Как видите, список отсортирован по Name по возрастанию, а поскольку у нас две Анны, они были отсортированы по DateOfBirth по убыванию.

Теперь попробуйте поменять порядок в запросе: https://localhost:5001/api/owner?orderBy=name desc,dateOfBirth

Теперь результат должен быть:

[
    {
        "id": "a3c1880c-674c-4d18-8f91-5d3608a2c937",
        "name": "Sam Query",
        "dateOfBirth": "1990-04-22T00:00:00",
        "address": "91 Western Roads"
    },
    {
        "id": "66774006-2371-4d5b-8518-2177bcf3f73e",
        "name": "Nick Somion",
        "dateOfBirth": "1998-12-15T00:00:00",
        "address": "North sunny address 102"
    },
    {
        "id": "f98e4d74-0f68-4aac-89fd-047f1aaca6b6",
        "name": "Martin Miller",
        "dateOfBirth": "1983-05-21T00:00:00",
        "address": "3 Edgar Buildings"
    },
    {
        "id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
        "name": "John Keen",
        "dateOfBirth": "1980-12-05T00:00:00",
        "address": "61 Wellfield Road"
    },
    {
        "id": "9c362f85-5581-4182-ac96-b7b88a74dda7",
        "name": "Anna Bosh",
        "dateOfBirth": "1964-11-14T00:00:00",
        "address": "24 Crescent Street"
    },
    {
        "id": "261e1685-cf26-494c-b17c-3546e65f5620",
        "name": "Anna Bosh",
        "dateOfBirth": "1974-11-14T00:00:00",
        "address": "27 Colored Row"
    }
]

Теперь вы можете попробовать разные недопустимые запросы, например:

https://localhost:5001/api/owner?orderBy=age https://localhost:5001/api/owner?orderBy=  https://localhost:5001/api/owner?orderBy=name desc&dateOfBirth

и убедиться в этом самостоятельно.

Заключение

Как вы видели, даже такая тривиальная сортировка требует определенной бизнес-логики, некоторого отражения, проверки и даже небольшого количества динамического построения запросов. Но как только вы его реализуете, ваш API станет действительно универсальным и гибким.

Эта реализация настолько проста, насколько это возможно, но есть некоторые вещи, которые мы можем сделать, чтобы ее улучшить. Мы можем реализовать сервисный слой (у нас его нет, чтобы упростить задачу), мы можем сделать наш код универсальным, а не только для конкретного владельца, и мы, конечно, можем создать метод расширения ApplySort для IQueryable, который сделает это решение еще более гибкое.

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

  • Определение сортировки и принцип ее работы
  • Один из способов реализации сортировки в веб-API ASP.NET Core
  • Тестирование нашего решения на допустимые и недействительные запросы.
  • Проверка совместимости нашего решения с разбиением по страницам, фильтрацией и поиском.