Формирование данных в ASP.NET Core Web API

Что такое формирование данных

Формирование данных - отличный способ уменьшить объем трафика, отправляемого от API к клиенту. Он позволяет потребителю API выбирать (формировать) данные, выбирая поля в строке запроса.

Под этим мы подразумеваем примерно следующее: https://localhost:5001/api/owner?fields=name,dateOfBirth

При этом API должен возвращать список владельцев ТОЛЬКО с полями Name и DateOfBirth объекта-владельца. В нашем случае у сущности-владельца не так много полей на выбор, но вы можете увидеть, как это может быть очень полезно, когда у сущности много полей. Это не такая уж редкость.

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

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

Наконец, как всегда, формирование данных должно хорошо сочетаться с концепциями, которые мы уже рассмотрели ранее - пагинацию, фильтрацию, поиск и сортировку.

Приступим к работе

Как реализовать формирование данных в веб-API ASP.NET Core

Перво-наперво, нам нужно расширить наш класс 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; }

	public string Fields { get; set; }
}

Мы добавили свойство Fields, и теперь мы можем использовать поля в качестве параметра строки запроса.

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

Мы сделаем IDataShaper.cs и DataShaper.cs в папке Helpers проекта Entities:

Сначала создадим интерфейс IDataShaper:

public interface IDataShaper<T>
{
	IEnumerable<ExpandoObject> ShapeData(IEnumerable<T> entities, string fieldsString);
	ExpandoObject ShapeData(T entity, string fieldsString);
}

IDataShaper определяет два метода, которые должны быть реализованы: один для отдельной сущности, а второй для коллекции сущностей. Оба названы ShapeData, но имеют разные подписи.

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

А теперь посмотрим на реализацию:

public class DataShaper<T> : IDataShaper<T>
{
	public PropertyInfo[] Properties { get; set; }

	public DataShaper()
	{
		Properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
	}

	public IEnumerable<ExpandoObject> ShapeData(IEnumerable<T> entities, string fieldsString)
	{
		var requiredProperties = GetRequiredProperties(fieldsString);

		return FetchData(entities, requiredProperties);
	}

	public ExpandoObject ShapeData(T entity, string fieldsString)
	{
		var requiredProperties = GetRequiredProperties(fieldsString);

		return FetchDataForEntity(entity, requiredProperties);
	}

	private IEnumerable<PropertyInfo> GetRequiredProperties(string fieldsString)
	{
		var requiredProperties = new List<PropertyInfo>();

		if (!string.IsNullOrWhiteSpace(fieldsString))
		{
			var fields = fieldsString.Split(',', StringSplitOptions.RemoveEmptyEntries);

			foreach (var field in fields)
			{
				var property = Properties.FirstOrDefault(pi => pi.Name.Equals(field.Trim(), StringComparison.InvariantCultureIgnoreCase));

				if (property == null)
					continue;

				requiredProperties.Add(property);
			}
		}
		else
		{
			requiredProperties = Properties.ToList();
		}

		return requiredProperties;
	}

	private IEnumerable<ExpandoObject> FetchData(IEnumerable<T> entities, IEnumerable<PropertyInfo> requiredProperties)
	{
		var shapedData = new List<ExpandoObject>();

		foreach (var entity in entities)
		{
			var shapedObject = FetchDataForEntity(entity, requiredProperties);
			shapedData.Add(shapedObject);
		}

		return shapedData;
	}

	private ExpandoObject FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
	{
		var shapedObject = new ExpandoObject();

		foreach (var property in requiredProperties)
		{
			var objectPropertyValue = property.GetValue(entity);
			shapedObject.TryAdd(property.Name, objectPropertyValue);
		}

		return shapedObject;
	}
}

Давайте разберем этот класс.

Описание реализации - шаг за шагом

У нас есть одно общедоступное свойство в этом классе - Properties. Это массив PropertyInfo, который мы собираемся извлечь из типа ввода, каким бы он ни был, в нашем случае - Account или Owner.

public PropertyInfo[] Properties { get; set; }

public DataShaper()
{
	Properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
}

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

Далее у нас есть реализация двух наших общедоступных методов ShapeData:

public IEnumerable<ExpandoObject> ShapeData(IEnumerable<T> entities, string fieldsString)
{
	var requiredProperties = GetRequiredProperties(fieldsString);

	return FetchData(entities, requiredProperties);
}

public ExpandoObject ShapeData(T entity, string fieldsString)
{
	var requiredProperties = GetRequiredProperties(fieldsString);

	return FetchDataForEntity(entity, requiredProperties);
}

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

Метод GetRequiredProperties - это чудо. Он анализирует входную строку и возвращает только те свойства, которые нам нужно вернуть контроллеру:

private IEnumerable<PropertyInfo> GetRequiredProperties(string fieldsString)
{
	var requiredProperties = new List<PropertyInfo>();

	if (!string.IsNullOrWhiteSpace(fieldsString))
	{
		var fields = fieldsString.Split(',', StringSplitOptions.RemoveEmptyEntries);

		foreach (var field in fields)
		{
			var property = Properties.FirstOrDefault(pi => pi.Name.Equals(field.Trim(), StringComparison.InvariantCultureIgnoreCase));

			if (property == null)
				continue;

			requiredProperties.Add(property);
		}
	}
	else
	{
		requiredProperties = Properties.ToList();
	}

	return requiredProperties;
}

Как видите, ничего особенного в этом нет. Если fieldsString не пуст, мы разбиваем его и проверяем, соответствуют ли поля свойствам в нашей сущности. Если да, мы добавляем их в список необходимых свойств.

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

Теперь FetchData и FetchDataForEntity являются частными методами для извлечения значений из этих необходимых свойств, которые мы подготовили:

FetchDataForEntity делает это для одного объекта:

private ExpandoObject FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
{
	var shapedObject = new ExpandoObject();

	foreach (var property in requiredProperties)
	{
		var objectPropertyValue = property.GetValue(entity);
		shapedObject.TryAdd(property.Name, objectPropertyValue);
	}

	return shapedObject;
}

Как видите, мы перебираем requiredProperties, а затем, используя немного рефлексии, извлекаем значения и добавляем их к нашему ExpandoObject. ExpandoObject реализует IDictionary<string,object>, поэтому мы можем использовать метод TryAdd для добавления нашего свойства, используя его имя в качестве ключа и значение в качестве значения для словаря.

Таким образом мы динамически добавляем только те свойства, которые нам нужны для нашего динамического объекта.

Метод FetchData - это просто реализация для нескольких объектов. Он использует только что реализованный нами метод FetchDataForEntity:

private IEnumerable<ExpandoObject> FetchData(IEnumerable<T> entities, IEnumerable<PropertyInfo> requiredProperties)
{
	var shapedData = new List<ExpandoObject>();

	foreach (var entity in entities)
	{
		var shapedObject = FetchDataForEntity(entity, requiredProperties);
		shapedData.Add(shapedObject);
	}

	return shapedData;
}

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

Это все для реализации, давайте посмотрим, как мы все это объединим в существующее решение.

Подключение IDataShaper

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

private IDataShaper<Owner> _dataShaper;

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

А затем примените формирование данных в методе GetOwners:

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

	SearchByName(ref owners, ownerParameters.Name);

	_sortHelper.ApplySort(owners, ownerParameters.OrderBy);
	var shapedOwners = _dataShaper.ShapeData(owners, ownerParameters.Fields);

	return PagedList<ExpandoObject>.ToPagedList(shapedOwners,
		ownerParameters.PageNumber,
		ownerParameters.PageSize);
}

Создайте еще один метод GetOwnerById, который формирует данные, поскольку нам все еще нужен наш обычный метод для проверок валидации в действиях контроллера:

public SerializableExpando GetOwnerById(Guid ownerId, string fields)
{
	var owner = FindByCondition(owner => owner.Id.Equals(ownerId))
		.DefaultIfEmpty(new Owner())
		.FirstOrDefault();

	return _dataShaper.ShapeData(owner, fields);
}

И не забудьте изменить интерфейс IOwnerRepository, чтобы отразить эти изменения:

public interface IOwnerRepository : IRepositoryBase<Owner>
{
	PagedList<ExpandoObject> GetOwners(OwnerParameters ownerParameters);
	ExpandoObject GetOwnerById(Guid ownerId, string fields);
	Owner GetOwnerById(Guid ownerId);
	void CreateOwner(Owner owner);
	void UpdateOwner(Owner dbOwner, Owner owner);
	void DeleteOwner(Owner owner);
}

Вы можете попробовать изменить AccountRepository самостоятельно для практики.

И, конечно же, поскольку мы изменили конструкторы классов репозитория, нам также необходимо изменить наш RepositoryWrapper:

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

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

			return _owner;
		}
	}

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

			return _account;
		}
	}

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

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

Использование внедрения зависимостей означает, что нам необходимо зарегистрировать наши формирователи данных в метод ConfigureRepositoryWrapper класса ServiceExtensions для их разрешения:

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

	services.AddScoped<IDataShaper<Owner>, DataShaper<Owner>>();
	services.AddScoped<IDataShaper<Account>, DataShaper<Account>>();

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

И поскольку мы проделали такую ​​потрясающую работу, нам не нужно изменять действие GetOwners в OwnerController, но нам нужно внести небольшое изменение в проверку в действии GetOwnerById:

[HttpGet("{id}", Name = "OwnerById")]
public IActionResult GetOwnerById(Guid id, [FromQuery] string fields)
{
	var owner = _repository.Owner.GetOwnerById(id, fields);

	if (owner == default(ExpandoObject))
	{
		_logger.LogError($"Owner with id: {id}, hasn't been found in db.");
		return NotFound();
	}

	return Ok(owner);
}

Устранение проблем сериализации Json

Теперь, если мы попытаемся запустить приложение в том виде, в котором оно есть сейчас, мы получим ошибку InvalidCastException. Причина в том, что System.Text не поддерживает преобразование ExpandoObject в IDictionary, что нам и нужно, когда мы возвращаем результат от контроллера.

Чтобы избежать этого, нам нужно добавить пакет NuGet Microsoft.AspNetCore.Mvc.NewtonsoftJson, а затем в методе ConfigureServices нашего класса Startup.cs найти строку services.AddControllers() и добавить метод .AddNewtonsoftJson() в конец:

public void ConfigureServices(IServiceCollection services)
{
	services.ConfigureCors();

	services.ConfigureIISIntegration();

	services.ConfigureLoggerService();

	services.ConfigureMySqlContext(Configuration);

	services.ConfigureRepositoryWrapper();

	services.AddControllers(config =>
	{
		config.RespectBrowserAcceptHeader = true;
		config.ReturnHttpNotAcceptable = true;
	}).AddXmlDataContractSerializerFormatters()
	.AddNewtonsoftJson();
}</pre>
Это заменяет сериализатор по умолчанию <code>System.Text.Json</code> на <code>Newtonsoft.Json</code>. Исключение должно исчезнуть, и приложение должно снова нормально работать.<p>Пока мы занимаемся этим, мы собираемся настроить приложение, чтобы оно учитывало заголовки браузера, возвращало 406 Not Acceptable, если запрашивается неизвестный тип носителя, и используем XML-сериализатор контракта данных, потому что мы хотим поддерживать согласование контента.</p><p>Это решает проблему с сериализацией json.</p>
<p>Но давайте проверим наше решение, чтобы убедиться, что оно действительно выполняет то, что мы указали.</p>
<h2>Тестирование нашего решения</h2>
<p>Прежде чем тестировать наше решение, давайте быстро посмотрим, как сейчас выглядит наша таблица Owner:</p>
<p><img src="https://forproger.ru/storage/article/content/ZL9ENosjxG_1609703809.png" class="article__image" loading="lazy"></p>
<p>Отлично, эти записи должны это делать.</p>
<p>Во-первых, давайте отправим простой запрос GET на конечную точку наших владельцев:
<code>https://localhost:5001/api/owner</code></p>
<p>Мы должны получить полный ответ:
</p> <pre>[
    {
        "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"
    }
]

Таким образом, наша функция формирования данных не изменила поведение приложения по умолчанию.

Теперь посмотрим на формирование данных в действии: https://localhost:5001/api/owner?fields=name,dateOfBirth

API должен возвращать сформированные данные:

[
    {
        "Name": "Sam Query",
        "DateOfBirth": "1990-04-22T00:00:00"
    },
    {
        "Name": "Nick Somion",
        "DateOfBirth": "1998-12-15T00:00:00"
    },
    {
        "Name": "Martin Miller",
        "DateOfBirth": "1983-05-21T00:00:00"
    },
    {
        "Name": "John Keen",
        "DateOfBirth": "1980-12-05T00:00:00"
    },
    {
        "Name": "Anna Bosh",
        "DateOfBirth": "1974-11-14T00:00:00"
    },
    {
        "Name": "Anna Bosh",
        "DateOfBirth": "1964-11-14T00:00:00"
    }
]

А теперь в завершение посмотрим, работает ли он с разбиением по страницам, фильтрацией, поиском и сортировкой: https://localhost:5001/api/owner?fields=name,dateOfBirth&pageSize=2&pageNumber=1&orderBy=name asc,dateOfBirth desc&maxYearOfBirth=1970

Этот запрос должен возвращать только один результат. Вы можете угадать, какой именно? Если вы угадали, оставьте нам комментарий с вашим мнением о результате.

Вот и все, мы успешно протестировали наш API.

Еще одна вещь, которую стоит проверить. Попробуем запросить результат в формате XML.

Устранение проблем сериализации XML

Давайте изменим заголовок Accept на application/xml и отправим запрос. Мы хотим проверить, работает ли наше согласование контента.

На этот раз мы отправим простой запрос: https://localhost:5001/api/owner/a3c1880c-674c-4d18-8f91-5d3608a2c937

И ответ выглядит так:

<ArrayOfKeyValueOfstringanyType xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
    <KeyValueOfstringanyType>
        <Key>Id</Key>
        <Value xmlns:d3p1="http://schemas.microsoft.com/2003/10/Serialization/" i:type="d3p1:guid">a3c1880c-674c-4d18-8f91-5d3608a2c937</Value>
    </KeyValueOfstringanyType>
    <KeyValueOfstringanyType>
        <Key>Name</Key>
        <Value xmlns:d3p1="http://www.w3.org/2001/XMLSchema" i:type="d3p1:string">Sam Query</Value>
    </KeyValueOfstringanyType>
    <KeyValueOfstringanyType>
        <Key>DateOfBirth</Key>
        <Value xmlns:d3p1="http://www.w3.org/2001/XMLSchema" i:type="d3p1:dateTime">1990-04-22T00:00:00</Value>
    </KeyValueOfstringanyType>
    <KeyValueOfstringanyType>
        <Key>Address</Key>
        <Value xmlns:d3p1="http://www.w3.org/2001/XMLSchema" i:type="d3p1:string">91 Western Roads</Value>
    </KeyValueOfstringanyType>
</ArrayOfKeyValueOfstringanyType>

Как видите, это выглядит довольно некрасиво и нечитабельно. Но именно так наш XmlDataContractSerializerOutputFormatter сериализует наш ExpandoObject по умолчанию.

Так хотим ли мы это исправить и как это сделать?

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

Итак, нам нужно создать что-то вроде этого:

public class Entity : DynamicObject, IXmlSerializable, IDictionary<string, object>
{
	//...
	//реализация
	//...
}

Главное, на что следует обратить внимание, это то, что мы наследуем от DynamicObject, который сделает наш объект динамическим, интерфейс IXmlSerializable, который нам нужен для реализации пользовательских правил сериализации, и IDictionary<string, object> из-за метода Add, который необходим для сериализации XML.

Все, что осталось, - это заменить тип ExpandoObject на тип Entity во всем нашем проекте.

Теперь мы должны получить такой ответ:

<Entity xmlns="http://schemas.datacontract.org/2004/07/Entities.Models">
    <Id>a3c1880c-674c-4d18-8f91-5d3608a2c937</Id>
    <Name>Sam Query</Name>
    <DateOfBirth>4/22/1990 12:00:00 AM</DateOfBirth>
    <Address>91 Western Roads</Address>
</Entity>

Выглядит намного лучше, не правда ли?

Если XML-сериализация для вас не важна, вы можете продолжать использовать ExpandoObject, но если вам нужен хорошо отформатированный XML-ответ, это способ.

Хорошо, давайте подведем итоги того, что мы уже сделали.

Заключение

Формирование данных - это интересная и удобная небольшая функция, которая действительно может сделать наши API гибкими и уменьшить сетевой трафик. Если у нас есть API с большим объемом трафика, формирование данных должно работать нормально. С другой стороны, это не та функция, которую мы должны использовать легкомысленно, потому что она использует рефлексию и динамическую типизацию для достижения цели.

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

  • Что такое формирование данных
  • Как реализовать универсальное решение для формирования данных в веб-API ASP.NET Core
  • Как решить наши проблемы с сериализацией json с помощью ExpandoObject
  • Тестирование нашего решения путем отправки нескольких простых запросов.
  • Форматирование наших ответов XML.