OData - очень интересная технология. В несколько строчек кода вы можете добавить к своему сервису возможность фильтрации данных, постраничной выборки, частичной выборки данных, ... Сегодня ей на смену приходит GraphQL, но OData всё ещё очень привлекательна.
Тем не менее, в её использовании есть ряд подводных камней, с которыми мне пришлось столкнуться. Здесь я хочу поделиться моим опытом работы с OData.
Простейшее использование
Для начала нам потребуется Web-сервис. Я создам его в помощью ASP.NET Core. Для того, чтобы использовать OData, нужно установить NuGet-пакет Microsoft.AspNetCore.OData. Теперь необходимо произвести настройку. Вот содержимое Program.cs:
var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services .AddControllers() .AddOData(opts => { opts .Select() .Expand() .Filter() .Count() .OrderBy() .SetMaxTop(1000); }); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseAuthorization(); app.MapControllers(); app.Run();
В методе AddOData мы указываем, какие именно операции из всех, возможных в OData, мы разрешаем.
Естественно, OData предназначен для работы с данными. Давайте добавим данные в наше приложение. Они будут очень простыми:
public class Author { [Key] public int Id { get; set; } [Required] public string FirstName { get; set; } [Required] public string LastName { get; set; } public string? ImageUrl { get; set; } public string? HomePageUrl { get; set; } public ICollection<Article> Articles { get; set; } } public class Article { [Key] public int Id { get; set; } public int AuthorId { get; set; } [Required] public string Title { get; set; } }
Я буду использовать Entity Framework для работы с ними. Тестовые данные я создам с помощью Bogus:
public class AuthorsContext : DbContext { public DbSet<Author> Authors { get; set; } = null!; public AuthorsContext(DbContextOptions<AuthorsContext> options) : base(options) { } public async Task Initialize() { await Database.EnsureDeletedAsync(); await Database.EnsureCreatedAsync(); var rnd = Random.Shared; Authors.AddRange( Enumerable .Range(0, 10) .Select(_ => { var faker = new Faker(); var person = faker.Person; return new Author { FirstName = person.FirstName, LastName = person.LastName, ImageUrl = person.Avatar, HomePageUrl = person.Website, Articles = new List<Article>( Enumerable .Range(0, rnd.Next(1, 5)) .Select(_ => new Article { Title = faker.Lorem.Slug(rnd.Next(3, 5)) }) ) }; }) ); await SaveChangesAsync(); } }
В качестве хранилища для данных я буду использовать расположенную в памяти Sqlite. Вот как я конфигурирую моё хранилище в Program.cs:
... var inMemoryDatabaseConnection = new SqliteConnection("DataSource=:memory:"); inMemoryDatabaseConnection.Open(); builder.Services.AddDbContext<AuthorsContext>(optionsBuilder => { optionsBuilder.UseSqlite(inMemoryDatabaseConnection); } ); ... using (var scope = app.Services.CreateScope()) { await scope.ServiceProvider.GetRequiredService<AuthorsContext>().Initialize(); } ...
Что ж, хранилище готово. Давайте создадим простой контроллер, возвращающий пользователю его данные:
[ApiController] [Route("/api/v1/authors")] public class AuthorsController : ControllerBase { private readonly AuthorsContext _db; public AuthorsController( AuthorsContext db ) { _db = db ?? throw new ArgumentNullException(nameof(db)); } [HttpGet("no-odata")] public ActionResult GetWithoutOData() { return Ok(_db.Authors); } }
Теперь по адресу /api/v1/authors/no-odata мы можем получить результат:
[ { "id": 1, "firstName": "Fred", "lastName": "Kuhlman", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg", "homePageUrl": "donald.com" }, { "id": 2, "firstName": "Darrel", "lastName": "Armstrong", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg", "homePageUrl": "angus.org" }, ... ]
Естественно, ни о какой поддержке OData пока речи нет. Но насколько тяжело её добавить?
Базовая поддержка OData
Очень легко. Давайте создадим ещё одну конечную точку:
[HttpGet("odata")] [EnableQuery] public IQueryable<Author> GetWithOData() { return _db.Authors; }
Как видите, отличия небольшие. Но теперь вы можете использовать OData в ваших запросах. Например, запрос вида /api/v1/authors/odata?$filter=id lt 3&$orderby=firstName даёт результат:
[ { "id": 2, "firstName": "Darrel", "lastName": "Armstrong", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg", "homePageUrl": "angus.org" }, { "id": 1, "firstName": "Fred", "lastName": "Kuhlman", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg", "homePageUrl": "donald.com" } ]
Замечательно. Но есть небольшой недостаток. Дело в том, что наш метод контроллера возвращает объект IQueryable<>. На практике же обычно нужно возвращать несколько вариантов ответов. Например, NotFound, BadRequest, ... Что же делать?
Оказывается, что реализация OData нормально работает в случае возврата IQueryable<>, обёрнутого в Ok:
[HttpGet("odata")] [EnableQuery] public IActionResult GetWithOData() { return Ok(_db.Authors); }
Это значит, что вы легко можете добавить в ваши действия контроллеров любую логику проверки.
Постраничная обработка
Как вы наверняка знаете, OData позволяет получить не полный результат, а только определённую страницу. Это делается с помощью операторов skip и top (например, /api/v1/authors/odata?$skip=3&$top=2). Нужно только не забыть вызвать метод SetMaxTop при конфигурации OData в Program.cs, иначе попытка использовать оператор top может привести к ошибке:
The query specified in the URI is not valid. The limit of '0' for Top query has been exceeded.
Но для полноценного использования механизма постраничного получения данных очень полезно знать, с��олько всего страниц у вас есть. Для этого нужно, чтобы наша конечная точка кроме самих данных одной страницы возвращала и общее количество данных, соответствующих указанному фильтру. Для этого в OData присутствует оператор count: (/api/v1/authors/odata?$skip=3&$top=2&$count=true). Но простое добавление его к запросу не приводит ни к какому результату. Чтобы получить то, что нам нужно, необходимо настроить EDM (entity data model). Но чтобы она заработала, нужно определиться с адресом нашей конечной точки.
Итак, пусть мы хотим получать наши данные по адресу /api/v1/authors/edm. По этому адресу мы будем возвращать объекты типа Author. Тогда настройка EDM производится так. В нашем Program.cs внесём некоторые изменения в настройку OData:
builder.Services .AddControllers() .AddOData(opts => { opts.AddRouteComponents("api/v1/authors", GetAuthorsEdm()); IEdmModel GetAuthorsEdm() { ODataConventionModelBuilder edmBuilder = new(); edmBuilder.EntitySet<Author>("edm"); return edmBuilder.GetEdmModel(); } opts .Select() .Expand() .Filter() .Count() .OrderBy() .SetMaxTop(1000); });
Обратите внимание на то, что имя маршрута для компонентов (api/v1/authors) совпадает с префиксом адреса для нашей конечной точки, а имя набора сущностей (entity set) совпадает с окончанием этого адреса (edm).
Чтобы это заработало, необходимо добавить к соответствующему методу контроллера атрибут ODataAttributeRouting:
[HttpGet("edm")] [ODataAttributeRouting] [EnableQuery] public IQueryable<Author> GetWithEdm() { return _db.Authors; }
Теперь данные, возвращаемые этой конечной точкой, для страничного запроса /api/v1/authors/edm?$top=2&$count=true будут иметь вид:
{ "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#edm", "@odata.count": 10, "value": [ { "Id": 1, "FirstName": "Steve", "LastName": "Schaefer", "ImageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/670.jpg", "HomePageUrl": "kylie.info" }, { "Id": 2, "FirstName": "Stella", "LastName": "Ankunding", "ImageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/884.jpg", "HomePageUrl": "allen.name" } ] }
Как видите, поле @odata.count содержит общее число элементов, удовлетворяющих фильтру запроса, что нам и требовалось.
Вообще же, вопрос установления соответствия EDM с конкретной конечной точкой оказался удивительно сложным для меня. Если желаете, можете попробовать разобраться в нём самостоятельно по документации или по примерам.
Определённую помощь может оказать отладочная страница, которую вы можете включить при конфигурации приложения:
if (app.Environment.IsDevelopment()) { app.UseODataRouteDebug(); }
После этого по адресу /$odata вы сможете посмотреть, какие конечные точки у вас есть, и ассоциированы ли с ними модели.
Сериализация JSON
Обратили ли вы внимание на то, какое изменение произошло с возвращаемыми нами данными после того, как мы добавили EDM? Все имена свойств стали начинаться с большой буквы (было firstName, а стало FirstName). На самом деле это может быть большой проблемой для JavaScript-клиентов, для которых существует разница между заглавными и строчными буквами. Нам нужно как-то управлять именами свойств возвращаемых объектов. OData использует System.Text.Json для сериализации данных. К сожалению, использование атрибутов этого пространства имён ничего не даёт:
[JsonPropertyName("firstName")] public string FirstName { get; set; }
По-видимому, OData берёт имена свойств из EDM, а не из определения класса.
Реализация OData от Microsoft предлагает нам два выхода в случае использования EDM. Первый из них позволяет включить "lower camel case" для всей модели при помощи вызова функции EnableLowerCamelCase:
IEdmModel GetAuthorsEdm() { ODataConventionModelBuilder edmBuilder = new(); edmBuilder.EnableLowerCamelCase(); edmBuilder.EntitySet<Author>("edm"); return edmBuilder.GetEdmModel(); }
Теперь мы получаем данные вида:
{ "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#edm", "@odata.count": 10, "value": [ { "id": 1, "firstName": "Troy", "lastName": "Gottlieb", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/228.jpg", "homePageUrl": "avery.net" }, { "id": 2, "firstName": "Mathew", "lastName": "Schiller", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/401.jpg", "homePageUrl": "marion.biz" } ] }
Это хорошо, но что если нам требуется более глубокий контроль над именами JSON-свойств? Что если нам нужно, чтобы некоторое свойство в JSON имело имя, неразрешённое для имён свойств в C# (как, например, @odata.count)?
Можно сделать и это через EDM. Давайте переименуем homePageUrl в @url.home:
IEdmModel GetAuthorsEdm() { ODataConventionModelBuilder edmBuilder = new(); edmBuilder.EnableLowerCamelCase(); edmBuilder.EntitySet<Author>("edm"); edmBuilder.EntityType<Author>() .Property(a => a.HomePageUrl).Name = "@url.home"; return edmBuilder.GetEdmModel(); }
Здесь нас ждёт неприятный сюрприз:
Microsoft.OData.ODataException: The property name '@url.home' is invalid; property names must not contain any of the reserved characters ':', '.', '@'.
Что ж, попробуем что-нибудь попроще:
edmBuilder.EntityType<Author>() .Property(a => a.HomePageUrl).Name = "url_home";
Так уже работает:
{ "url_home": "danielle.info", "id": 1, "firstName": "Armando", "lastName": "Hammes", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/956.jpg" },
Неприятно, конечно, но что поделаешь.
Преобразование данных
До сих пор мы предоставляли пользователю данные непосредственно из базы данных. Но обычно в крупных приложениях принято осуществлять разделение между классами, ответственными за хранение информации, и классами, ответственными за предоставление данных пользователю. По крайней мере это позволяет менять эти классы относительно независимо. Давайте посмотрим, как этот механизм работает с OData.
Я создам простые обёртки наших классов:
public class AuthorDto { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string? ImageUrl { get; set; } public string? HomePageUrl { get; set; } public ICollection<ArticleDto> Articles { get; set; } } public class ArticleDto { public string Title { get; set; } }
Для преобразования я буду пользоваться AutoMapper. С Mapster я глубоко не разбирался, но знаю, что он тоже поддерживает работу с Entity Framework.
Для AutoMapper необходимо создать нужное нам преобразование:
public class DefaultProfile : Profile { public DefaultProfile() { CreateMap<Article, ArticleDto>(); CreateMap<Author, AuthorDto>(); } }
и зарегистрировать его при старте приложения (для чего используется NuGet-пакет AutoMapper.Extensions.Microsoft.DependencyInjection):
builder.Services.AddAutoMapper(typeof(Program).Assembly);
Теперь я могу создать ещё одну конечную точку на моём контроллере:
... private readonly IMapper _mapper; private readonly AuthorsContext _db; public AuthorsController( IMapper mapper, AuthorsContext db ) { _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); _db = db ?? throw new ArgumentNullException(nameof(db)); } ... [HttpGet("mapping")] [EnableQuery] public IQueryable<AuthorDto> GetWithMapping() { return _db.Authors.ProjectTo<AuthorDto>(_mapper.ConfigurationProvider); }
Как видите, преобразование осуществляется довольно просто. К сожалению, возвращаемые данные содержат развёрнутый список статей автора:
[ { "id": 1, "firstName": "Edward", "lastName": "O'Kon", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1162.jpg", "homePageUrl": "zachariah.info", "articles": [ { "title": "animi-sint-atque" }, { "title": "aut-eum-iure" } ] }, ... ]
Т. е. мы фактически утратили возможность произвольно выполнять операцию expand OData. Что ж, это дело поправимое. Давайте немного изменим конфигурацию AutoMapper для AuthorDto:
CreateMap<Author, AuthorDto>() .ForMember(a => a.Articles, o => o.ExplicitExpansion());
Теперь на запрос /api/v1/authors/mapping нам возвращаются правильные данные:
[ { "id": 1, "firstName": "Spencer", "lastName": "Cummerata", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/286.jpg", "homePageUrl": "woodrow.info" }, ... ]
А на запрос /api/v1/authors/mapping?$expand=articles:
InvalidOperationException: The LINQ expression '$it => new SelectAll<ArticleDto>{ Model = __TypedProperty_1, Instance = $it, UseInstanceForProperties = True } ' could not be translated.
Да, проблемка. Но AutoMapper предос��авляет нам ещё один способ работать с OData. Существует пакет AutoMapper.AspNetCore.OData.EFCore. С его помощью я могу реализовать свою конечную точку так:
[HttpGet("automapper")] public IQueryable<AuthorDto> GetWithAutoMapper(ODataQueryOptions<AuthorDto> query) { return _db.Authors.GetQuery(_mapper, query); }
Обратите внимание, что мы не помечаем наш метод атрибутом EnableQuery. Вместо этого мы собираем передаваемые в запросе OData-параметры в объект ODataQueryOptions и применяем необходимые преобразование "вручную".
На этот раз всё работает нормально: и запрос без развёртывания:
[ { "id": 1, "firstName": "Nathan", "lastName": "Heller", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg", "homePageUrl": "jamarcus.biz", "articles": null }, ... ]
и запрос с развёртыванием:
[ { "id": 1, "firstName": "Nathan", "lastName": "Heller", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg", "homePageUrl": "jamarcus.biz", "articles": [ { "title": "quidem-nulla-et" } ] }, ... ]
Кроме того, у этого подхода есть ещё одно достоинство. Он позволяет использовать стандартные JSON-инструменты для управления сериализацией наших объектов. Например, мы можем убрать свойства, имеющие значение null из наших результатов, настроив сериализацию:
builder.Services .AddJsonOptions(configure => { configure.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; configure.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; });
Далее, мы можем управлять именами свойств через атрибуты:
[JsonPropertyName("@url.home")] public string? HomePageUrl { get; set; }
Теперь м�� можем дать нашему свойству такое имя:
[ { "id": 1, "firstName": "Edward", "lastName": "Schmidt", "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1046.jpg", "@url.home": "justen.com" }, ... ]
Добавление данных
Если хранимые в базе данных поля отображаются в возвращаемую пользователю информацию один в один, то проблем нет. Но так бывает не всегда. Зачастую нам хочется, чтобы возвращаемые пользователю данные представляли собой результат некоторой обработки хранимой информации. В этом случае может быть несколько вариантов.
Во-первых, преобразование данных может быть простым. Например, я хочу возвращать не имя и фамилию по-отдельности, а полное имя автора:
public class ComplexAuthor { [Key] public int Id { get; set; } public string FullName { get; set; } }
Мы можем настроить отображение AutoMapper для этого класса так:
CreateMap<Author, ComplexAuthor>() .ForMember(d => d.FullName, opt => opt.MapFrom(s => s.FirstName + " " + s.LastName));
В данном случае мы получаем искомый результат:
[ { "id": 1, "fullName": "Lance Rice" }, ... ]
Более того, мы по-прежнему можем фильтровать и упорядочивать наши записи по нашему новому полю (/api/v1/authors/nonsql?$filter=startswith(fullName,'A')):
[ { "id": 4, "fullName": "Andre Medhurst" }, { "id": 6, "fullName": "Amber Terry" } ]
Дело здесь в том, что наше простое выражение (s.FirstName + " " + s.LastName) может быть легко преобразовано в часть SQL запроса. Вот какой запрос сгенерировал для меня в данном случае Entity Framework:
SELECT "a"."Id", ("a"."FirstName" || ' ') || "a"."LastName" FROM "Authors" AS "a" WHERE (@__TypedProperty_0 = '') OR (((("a"."FirstName" || ' ') || "a"."LastName" LIKE @__TypedProperty_0 || '%') AND (substr(("a"."FirstName" || ' ') || "a"."LastName", 1, length(@__TypedProperty_0)) = @__TypedProperty_0)) OR (@__TypedProperty_0 = ''))
Именно поэтому операции фильтрации и упорядочивания продолжают работать.
Но, очевидно, не все преобразования данных могут быть переведены в SQL. Предположим, например, что нам по какой-то причине потребовалось считать хэш нашего полного имени:
public class ComplexAuthor { [Key] public int Id { get; set; } public string FullName { get; set; } public string NameHash { get; set; } }
Теперь инструкции для AutoMapper выглядят так:
CreateMap<Author, ComplexAuthor>() .ForMember(d => d.FullName, opt => opt.MapFrom(s => s.FirstName + " " + s.LastName)) .ForMember( d => d.NameHash, opt => opt.MapFrom(a => string.Join(",", SHA256.HashData(Encoding.UTF32.GetBytes(a.FirstName + " " + a.LastName)))) );
Давайте попробуем получить наши данные:
[ { "id": 1, "fullName": "Julius Haag", "nameHash": "66,19,82,19,233,224,181,226,111,125,241,228,81,6,200,47,5,112,248,30,186,26,173,91,83,73,9,137,6,158,138,115" }, { "id": 2, "fullName": "Anita Wilderman", "nameHash": "196,131,191,35,182,3,174,193,196,91,70,199,22,173,72,54,123,73,110,83,254,178,19,129,219,24,137,197,83,158,76,209" }, ... ]
Интересно. Несмотря на то, что результирующее выражение не может быть выражено в терминах SQL, но система всё ещё продолжает работать. Видимо Entity Framework знает, какие вычисления можно выполнить на стороне сервера.
Попробуем теперь фильтровать данные по нашему новому полю (nameHash): /api/v1/authors/nonsql?$filter=nameHash eq '1'
InvalidOperationException: The LINQ expression 'DbSet<Author>() .Where(a => (string)string.Join<byte>( separator: ",", values: SHA256.HashData(__UTF32_0.GetBytes(a.FirstName + " " + a.LastName))) == __TypedProperty_1)' could not be translated.
Теперь мы уже не можем избежать преобразования нашего выражения в SQL. И, поскольку оно не может быть выполнено, мы получаем сообщение об ошибке.
В данном случае, если не удаётся переписать выражение так, чтобы оно могло быть конвертируемо в SQL, можно попробовать запретить фильтрацию и сортировку по данному полю. Для этого существуют атрибуты NonFilterable и NotFilterable, NotSortable и Unsortable. Вы можете использовать любые из этих пар:
public class ComplexAuthor { [Key] public int Id { get; set; } public string FullName { get; set; } [NonFilterable] [Unsortable] public string NameHash { get; set; } }
Мне бы лично хотелось, чтобы при попытке фильтровать по нашему полю, пользователю возвращался Bad Request. Но само по себе добавление этих атрибутов ничего не даёт. Фильтрация по nameHash приводит к появлению всё той же ошибки. Необходимо провести валидацию запроса вручную:
[HttpGet("nonsql")] public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options) { try { options.Validator.Validate(options, new ODataValidationSettings()); } catch (ODataException e) { return BadRequest(e.Message); } return Ok(_db.Authors.GetQuery(_mapper, options)); }
Теперь при попытке фильтрации мы получаем сообщение:
The property 'NameHash' cannot be used in the $filter query option.
Так уже лучше. Хотя возвращаемое пользователю имя свойства начинается с маленькой буквы (nameHash), а не с большой (NameHash).
Интересно, а как вообще обстоят дела с изменением имён свойств с помощью атрибута JsonPropertyName? Например, я хочу, чтобы имя называлось name:
[JsonPropertyName("name")] public string FullName { get; set; }
Могу ли я теперь фильтровать по name (/api/v1/authors/nonsql?$filter=startswith(name,'A'))? Оказывается, нет:
Could not find a property named 'name' on type 'ODataJourney.Models.ComplexAuthor'.
А что, если вернуться к EDM? Для этого достаточно добавить атрибут ODataAttributeRouting к методу контроллера:
[HttpGet("nonsql")] [ODataAttributeRouting] public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options)
И прописать нашу модель:
... edmBuilder.EntitySet<ComplexAuthor>("nonsql"); edmBuilder.EntityType<ComplexAuthor>() .Property(a => a.FullName).Name = "name"; ...
Теперь мы можем фильтровать по name:
{ "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#nonsql", "value": [ { "name": "Leona Bauch", "id": 3, "nameHash": "56,114,131,251,22,63,188,105,37,55,74,232,36,181,152,24,9,111,131,55,229,89,164,181,230,158,109,163,206,137,147,173" }, { "name": "Leo Schimmel", "id": 7, "nameHash": "78,48,88,216,170,3,241,99,96,251,10,176,45,187,250,58,240,215,104,159,26,158,217,244,93,219,183,119,206,40,130,102" } ] }
Но, как видите, структура возвращаемых данных поменялась. Мы получили OData-обёртку. Кроме того, мы вернулись к описанному выше ограничению на имена свойств.
В заключении давайте рассмотрим ещё один тип преобразования возвращаемых данных. До сих пор мы делали это преобразование с помощью AutoMapper. Но в таком случае мы не можем использовать контекст запроса. Преобразования AutoMapper описываются в отдельном файле, где нет доступа к информации, поступающей к нам в запросе. Но иногда это бывает важным. Например, мы хотим выполнить Web-запрос на основе полученных в запросе данных и изменить возвращаемую пользователю информацию на основе результатов этого запроса. В следующем примере я использую простой цикл foreach для представления некоторой серверной обработки данных:
[HttpGet("add")] public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options) { try { options.Validator.Validate(options, new ODataValidationSettings()); } catch (ODataException e) { return BadRequest(e.Message); } var query = _db.Authors.ProjectTo<ComplexAuthor>(_mapper.ConfigurationProvider); var authors = query.ToArray(); foreach (var author in authors) { author.FullName += " (Mr)"; } return Ok(authors); }
Естественно, ни о какой OData тут речи не идёт. Но как мы можем включить поддержку OData? Нам бы не хотелось терять возможность фильтрации, сортировки и разбиения на страницы.
Возможным выходом будет следующий подход. Можно сначала выполнить все запрошенные операции кроме select. В данном случае мы всё ещё будем работать с полным объектом ComplexAuthor. После этого мы внесём в полученные данные наши изменения, а затем применим операцию select, если она была запрошена. Это позволит нам получить из базы данных только небольшое количество записей, соответствующих нашему фильтру и странице:
[HttpGet("add")] public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options) { try { options.Validator.Validate(options, new ODataValidationSettings()); } catch (ODataException e) { return BadRequest(e.Message); } var query = _db.Authors.ProjectTo<ComplexAuthor>( _mapper.ConfigurationProvider); var authors = options .ApplyTo(query, AllowedQueryOptions.Select) .Cast<ComplexAuthor>() .ToArray(); foreach (var author in authors) { author.FullName += " (Mr)"; } var result = options.ApplyTo( authors.AsQueryable(), AllowedQueryOptions.All & ~AllowedQueryOptions.Select ); return Ok(result); }
Объект ODataQueryOptions позволяет указывать, какие именно операции OData должны применяться. Это позволяет нам разделить их на два этапа, между которыми мы и включаем свою обработку.
У этого подхода есть свои недостатки. Во-первых, мы опять потеряли возможность конфигурировать имена свойств наших объектов через JSON-атрибуты. Это можно исправить введением EDM, но за это приходится платить изменением формы возвращаемых данных (появляется OData-обёртка).
Кроме того, возвращается проблема с операцией expand. Наш класс ComplexAuthor был достаточно прост, но в него легко можно добавить свойство, возвращающее соответствующие статьи:
public ICollection<ArticleDto> Articles { get; set; }
Использованный нами ранее метод GetQuery из пакета AutoMapper.AspNetCore.OData.EFCore не позволяет частично выполнять операции OData. А без него мне не удалось заставить систему корректно разворачивать Articles. Я дошёл до непонятной ошибки:
ODataException: Property 'articles' on type 'ODataJourney.Models.ComplexAuthor' is not a navigation property or complex property. Only navigation properties can be expanded.
Может у кого-то получится преодолеть её.
Заключение
Несмотря на то, что OData предоставляет достаточно простой способ добавить мощные операции по фильтрации данных к вашему Web API, добиться от текущей реализации Microsoft всего, чего хочется, оказывается очень сложно. Создаётся стойкое впечатление, что когда прикручиваешь что-то одно, что-то другое отваливается.
Будем надеяться, что я здесь чего-то не понял, и есть надёжный способ преодолеть все эти трудности. Удачи!
P.S. Исходный код для этой статьи вы можете найти на GitHub.
