Что такое формирование данных
Формирование данных - отличный способ уменьшить объем трафика, отправляемого от 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.