Pull to refresh

OdataToEntity легкий способ создания .Net Core OData сервисов

Reading time 12 min
Views 11K

Когда вышел .Net Core, старая версии OData ASP.NET Web API оказалась несовместимой с новой платформой. Этот фатальный недостаток позволил мне создать свою реализацию OData на платформе .Net Core. В результате творческого переосмысления предыдущей реализации пришло понимание, что она страдала от переусложненного дизайна с большим количеством ненужных абстракций. Возникла идея создать простую в использовании, требующую минимального кодирования библиотеку. Представляю вашему вниманию OdataToEntity, библиотеку создания OData сервисов без написания кода, нужен только контекст доступа к данным. Как следствие, для упрощения дизайна api было принято решение не использовать в коде интерфейсы, при этом библиотека имеет полное покрытие тестами. Для уменьшения внешних зависимостей библиотека отвязана от HTTP, что позволяет реализовать OData поверх любого транспорта. Это чудо инженерной мысли собирается под Framework 4.8 или .Net Core 3.1 и использует Microsoft.OData.Core 7.6. Поддерживаются следующие контексты данных:


  1. Entity Framework 6.4
  2. Entity Framework Core 3.1
  3. Linq2Db

Как это работает


Основная идея проекта — трансляция OData запросов в дерево выражений, которое потом передается к соответствующему адаптеру доступа к данным.


Для изоляции библиотеки от различных ORM API используется абстрактный класс "адаптер доступа к данным" OdataToEntity.Db.OeDataAdapter. Каждый контекст реализует своего наследника от этого класса (Ef6: OeEf6DataAdapter, EfCore: OeEfCoreDataAdapter, Linq2Db: OeLinq2DbDataAdapter).


По модели данных строится OData Entity Data Model (EDM) модель описания данных, предоставляемых вашим сервисом. EDM модель необходима библиотеке ODataLib для разбора строки запроса. Если сущности пользователя размечены атрибутами (System.ComponentModel.DataAnnotations), то модель можно построить универсальным способом, подходящим для всех поставщиков данных.


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Build OData Edm Model
EdmModel edmModel = dataAdapter.BuildEdmModel();

Если используется контекст Entity Framework и для описания сущностей используется "Fluent API" (без использования атрибутов):
Entity Framework Core


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Build OData Edm Model
EdmModel edmModel = dataAdapter.BuildEdmModelFromEfCoreModel();

Entity Framework 6


//Create adapter data access, where OrderEf6Context your DbContext
var dataAdapter = new OeEf6DataAdapter<OrderEf6Context>();
//Build OData Edm Model
EdmModel edmModel = dataAdapter.BuildEdmModelFromEf6Model();

Создать модель из нескольких контекстов данных


//Create referenced data adapter
var refDataAdapter = new OeEfCoreDataAdapter<Model.Order2Context>();
//Build referenced Edm Model
EdmModel refModel = refDataAdapter.BuildEdmModel();

//Create root data adapter
var rootDataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Build root Edm Model
EdmModel rootModel = rootDataAdapter.BuildEdmModel(refModel);

Библиотека может использоваться для чтения и редактирования данных.
В режиме чтения на вход библиотеке подается OData запрос, он c помощью Microsoft.OData.Core (ODataLib) парсится в представление ODataLib, которое транслируется в обычное дерево выражений. Выполняется параметризация запроса (т.е. замена константных выражений на переменные) и затем передается в адаптер доступа к данным. Адаптер транслирует общее древо выражений в более конкретное, применимое в данном контексте данных. Создает контекст, который выполняет запрос к базе данных, и полученные сущности сериализуются в OData JSON формате.
В режиме редактирования на вход библиотеке подаются сущности модели, сериализованные в OData JSON формате. С помощью ODataLib десериализуется в сущности модели данных, которые добавляются в контекст доступа к данным и сохраняются в базе данных. Поля, вычисляемые на стороне базы данных, возвращаются клиенту. Поддерживается "batch change set" — пакетное добавление, удаление, изменение сущностей. Редактирование таблиц, описывающих древовидные структуры данных (self-referencing table). Для Linq2Db был реализован контекст данных подобный DbContext Entity Framework, позволяющий редактировать граф объектов.


Поддерживаемые виды запросов


  1. $apply (filter, groupby, aggregate (average, count, virtual property $count, countdistinct, max, min, sum))
  2. $count
  3. $filter
  4. $orderby
  5. $select
  6. $skip
  7. $top
  8. $compute
  9. $skiptoken
  10. lamda any
  11. lambda all

Поддерживаемые функции


  1. cast
  2. ceiling
  3. concat
  4. contains
  5. day
  6. endswith
  7. floor
  8. fractionalseconds
  9. hour
  10. indexof
  11. length
  12. minute
  13. month
  14. round
  15. second
  16. startswith
  17. substring
  18. tolower
  19. toupper
  20. trim
  21. year

Пример использования


В тестах и примерах используется следующая модель данных


public sealed class Category
{
    public ICollection<Category> Children { get; set; }
    public int Id { get; set; }
    [Required]
    public String Name { get; set; }
    public Category Parent { get; set; }
    public int? ParentId { get; set; }
}

public sealed class Customer
{
    public String Address { get; set; }
    [InverseProperty(nameof(Order.AltCustomer))]
    public ICollection<Order> AltOrders { get; set; }
    [Key, Column(Order = 0), Required]
    public String Country { get; set; }
    [Key, Column(Order = 1)]
    public int Id { get; set; }
    [Required]
    public String Name { get; set; }
    [InverseProperty(nameof(Order.Customer))]
    public ICollection<Order> Orders { get; set; }
    public Sex? Sex { get; set; }
}

public sealed class Order
{
    [ForeignKey("AltCustomerCountry,AltCustomerId")]
    public Customer AltCustomer { get; set; }
    public String AltCustomerCountry { get; set; }
    public int? AltCustomerId { get; set; }

    [ForeignKey("CustomerCountry,CustomerId")]
    public Customer Customer { get; set; }
    public String CustomerCountry { get; set; }
    public int CustomerId { get; set; }

    public DateTimeOffset? Date { get; set; }
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public ICollection<OrderItem> Items { get; set; }
    [Required]
    public String Name { get; set; }
    public OrderStatus Status { get; set; }
}

public sealed class OrderItem
{
    public int? Count { get; set; }
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public Order Order { get; set; }
    public int OrderId { get; set; }
    public Decimal? Price { get; set; }
    [Required]
    public String Product { get; set; }
}

public enum OrderStatus
{
    Unknown,
    Processing,
    Shipped,
    Delivering,
    Cancelled
}

public enum Sex
{
    Male,
    Female
}

public sealed class OrderContext : DbContext
{
    public DbSet<Category> Categories { get; set; }
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }
    public DbSet<OrderItem> OrderItems { get; set; }

    [Description("dbo.GetOrders")]
    public IEnumerable<Order> GetOrders(int? id, String name, OrderStatus? status) => throw new NotImplementedException();
    public void ResetDb() => throw new NotImplementedException();
}

Пример выполнения OData запроса состоит всего из пяти строчек:


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Create query parser
var parser = new OeParser(new Uri("http://dummy"), dataAdapter, dataAdapter.BuildEdmModel());
//Query
var uri = new Uri("http://dummy/Orders?$select=Name");
//The result of the query
var response = new MemoryStream();
//Execute query
await parser.ExecuteGetAsync(uri, OeRequestHeaders.JsonDefault, response, CancellationToken.None);

Пример сохранения новых сущностей в базе данных также состоит из пяти строчек:


string batch = @"
--batch_6263d2a1-1ddc-4b02-a1c1-7031cfa93691
Content-Type: multipart/mixed; boundary=changeset_e9a0e344-4133-4677-9be8-1d0006e40bb6

--changeset_e9a0e344-4133-4677-9be8-1d0006e40bb6
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 1

POST http://dummy/Customers HTTP/1.1
OData-Version: 4.0
OData-MaxVersion: 4.0
Content-Type: application/json;odata.metadata=minimal
Accept: application/json;odata.metadata=minimal
Accept-Charset: UTF-8
User-Agent: Microsoft ADO.NET Data Services

{""@odata.type"":""#OdataToEntity.Test.Model.Customer"",""Address"":""Moscow"",""Id"":1,""Name"":""Ivan"",""Sex@odata.type"":""#OdataToEntity.Test.Model.Sex"",""Sex"":""Male""}

--changeset_e9a0e344-4133-4677-9be8-1d0006e40bb6--
--batch_6263d2a1-1ddc-4b02-a1c1-7031cfa93691--
";

//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Create query parser
var parser = new OeParser(new Uri("http://dummy"), dataAdapter, dataAdapter.BuildEdmModel());
//Serialized entities in JSON UTF8 format
var request = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(batch));
//The result of the query
var response = new MemoryStream();
//Execute query
await parser.ExecuteBatchAsync(request, response, CancellationToken.None);

Пример выполнения хранимой процедуры


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>();
//Create query parser
var parser = new OeParser(new Uri("http://dummy"), dataAdapter, dataAdapter.BuildEdmModel());
//The result of the stored procedure
var response = new MemoryStream();
//Execute sored procedure
await parser.ExecuteGetAsync(new Uri("http://dummy/GetOrders(name='Order 1',id=1,status=null)"), OeRequestHeaders.JsonDefault, response, CancellationToken.None);

Для задания имени процедуры, отличного от имени метода в c#, можно использовать атрибут


 [Description("dbo.GetOrders")]
public IEnumerable<Order> GetOrders(int? id, String name, OrderStatus? status) => throw new NotImplementedException();

Другие примеры можно найти в папке test


Постраничная выборка данных
Server-Driven Paging позволяет получить частичный набор данных размер которого устанавливается через метод OeRequestHeaders.SetMaxPageSize(int maxPageSize). Сервер возвращает данные и ссылку на следующею часть в аннотации @odata.nextLink, где меткой $skiptoken записано начало следующей части данных. Если запрос возвращает данные где в сортировке участвует столбец допускающий значения NULL в базе данных (не указан атрибут Required), необходимо установить свойство OeDataAdapter.IsDatabaseNullHighestValue для SQLite, MySql, Sql Server в false, для PostgreSql, Oracle в true.


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>(Model.OrderContext.CreateOptions())
{
  IsDatabaseNullHighestValue = true //PostgreSql
};
//Create query parser
var parser = new OeParser(new Uri("http://dummy"), dataAdapter, dataAdapter.BuildEdmModel());
//Query
var uri = new Uri("http://dummy/Orders?$select=Name&$orderby=Date");
//Set max page size
OeRequestHeaders requestHeaders = OeRequestHeaders.JsonDefault.SetMaxPageSize(10);
//The result of the query
var response = new MemoryStream();
//Execute query
await parser.ExecuteGetAsync(uri, requestHeaders, response, CancellationToken.None);

Если необходимо получить ссылки, а не реальные данные навигационных свойств один ко многим, необходимо вызывать метод OeRequestHeaders.SetNavigationNextLink(true).


//Query
var uri = new Uri("http://dummy/Orders?$expand=Items");
//Set max page size,  to-many navigation properties
OeRequestHeaders requestHeaders = OeRequestHeaders.JsonDefault.SetMaxPageSize(10).SetNavigationNextLink(true);
//The result of the query
var response = new MemoryStream();
//Execute query
await parser.ExecuteGetAsync(uri, requestHeaders, response, CancellationToken.None);

Возможности специфичные для Entity Framework Core
Для провайдера Entity Framework Core существует возможность кэширования запросов, что на существующих тестах позволяет поднять скорость выборки до двух раз. Ключом кэша выступает разобранный OData запрос с удаленными константными значениями, а значением — делегат, принимающий контекст данных и возвращающий результат запроса. Это позволяет исключить стадию построения самого запроса к данным (IQueryable). Для использования этой возможности используйте конструктор OeEfCoreDataAdapter(DbContextOptions options, Db.OeQueryCache queryCache).
Для использование пула объектов DbContext (DbContextPool) создайте экземпляр OeEfCoreDataAdapter через конструктор с параметром DbContextOptions


//Create adapter data access, where OrderContext your DbContext
var dataAdapter = new OeEfCoreDataAdapter<Model.OrderContext>(Model.OrderContext.CreateOptions());

Структура исходного кода
Исходный код разделен на две части: в папке source — сама библиотека и сборки доступа к различным источникам данных, в папке test — тесты и примеры кода.
Файлы солюшенов в папке sln.
Сама библиотека находится в проекте source/OdataEntity.
Адаптер к контексту Entity Framework 6.2 source/OdataToEntity.Ef6.
Адаптер к контексту Entity Framework Core source/OdataToEntity.EfCore.
Адаптер к контексту Linq2Db source/OdataToEntity.Linq2Db.
Роутинг и базовые классы контроллеров для Asp .Net Core Mvc source/OdataToEntity.AspNetCore


Тесты:
Entity Framework Core in-memory database test/OdataToEntity.Test
Entity Framework Core Sql Server test/OdataToEntity.Test.EfCore.SqlServer
Entity Framework Core PostgreSql test/OdataToEntity.Test.EfCore.PostgreSql
Entity Framework 6 Sql Server test/OdataToEntity.Test.Ef6.SqlServer
Linq2Db Sql Server test/OdataToEntity.Test.Linq2Db


Примеры запросов можно посмотреть в тестах


Тестируемые OData запросы

OrderItems?$apply=filter(Order/Status eq OdataToEntity.Test.Model.OrderStatus'Processing')
Orders?$apply=filter(Status eq OdataToEntity.Test.Model.OrderStatus'Unknown')/groupby((Name), aggregate(Id with countdistinct as cnt))
OrderItems?$apply=groupby((Product))
OrderItems?$apply=groupby((OrderId, Order/Status), aggregate(Price with average as avg, Product with countdistinct as dcnt, Price with max as max, Order/Status with max as max_status, Price with min as min, Price with sum as sum, $count as cnt))
OrderItems?$apply=groupby((OrderId), aggregate(Price with sum as sum))/filter(OrderId eq 2 and sum ge 4)
OrderItems?$apply=groupby((OrderId), aggregate(Price with sum as sum))&$filter=OrderId eq 2
OrderItems?$apply=groupby((OrderId, Order/Name))/filter(OrderId eq 1 and Order/Name eq 'Order 1')
OrderItems?$apply=groupby((OrderId), aggregate(Price mul Count with sum as sum))
OrderItems?$apply=groupby((OrderId, Order/Name))&$orderby=OrderId desc, Order/Name
OrderItems?$apply=groupby((OrderId, Order/Name))&$orderby=OrderId desc, Order/Name&$skip=1&$top=1
OrderItems?$apply=groupby((OrderId))&$orderby=OrderId&$skip=1
OrderItems?$apply=groupby((OrderId))&$top=1
OrderItems?$apply=groupby((OrderId), aggregate(substring(Product, 0, 10) with countdistinct as dcnt, $count as cnt))/filter(dcnt ne cnt)
Orders/$count
Orders?$expand=Customer,Items&$orderby=Id
Orders?$expand=AltCustomer,Customer,Items&$select=AltCustomerCountry,AltCustomerId,CustomerCountry,CustomerId,Date,Id,Name,Status&$orderby=Id
Customers?$expand=AltOrders($expand=Items($filter=contains(Product,'unknown'))),Orders($expand=Items($filter=contains(Product,'unknown')))
Customers?$expand=AltOrders($expand=Items),Orders($expand=Items)
OrderItems?$expand=Order($expand=AltCustomer,Customer)&$orderby=Id
Customers?$expand=Orders($expand=Items($orderby=Id desc))
Customers?$orderby=Id&$skip=1&$top=3&$expand=AltOrders($expand=Items($top=1)),Orders($expand=Items($top=1))
Customers?$expand=Orders($filter=Status eq OdataToEntity.Test.Model.OrderStatus'Processing')
Customers?$expand=AltOrders,Orders
Customers?$expand=Orders($select=AltCustomerCountry,AltCustomerId,CustomerCountry,CustomerId,Date,Id,Name,Status)
Orders?$expand=*&$orderby=Id
Orders?$filter=Items/all(d:d/Price ge 2.1)
Orders?$filter=Items/any(d:d/Count gt 2)
Orders?$filter=Status eq OdataToEntity.Test.Model.OrderStatus'Unknown'&$apply=groupby((Name), aggregate(Id with countdistinct as cnt))
Orders?$filter=Items/$count gt 2
Orders?$filter=Date ge 2016-07-04T19:10:10.8237573%2B03:00
Orders?$filter=year(Date) eq 2016 and month(Date) gt 3 and day(Date) lt 20
Orders?$filter=Date eq null
OrderItems?$filter=Price gt 2
OrderItems?$filter=Price eq null
Customers?$filter=Sex eq OdataToEntity.Test.Model.Sex'Female'
Customers?$filter=Sex eq null
Customers?$filter=Sex ne null and Address ne null
Customers?$filter=Sex eq null and Address ne null
Customers?$filter=Sex eq null and Address eq null
OrderItems?$filter=Count ge 2
OrderItems?$filter=Count eq null
OrderItems?$filter=Order/Customer/Name eq 'Ivan'
Customers?$filter=Address eq 'Tula'
Customers?$filter=concat(concat(Name,' hello'),' world') eq 'Ivan hello world'
Customers?$filter=contains(Name, 'sh')
Customers?$filter=endswith(Name, 'asha')
Customers?$filter=length(Name) eq 5
Customers?$filter=indexof(Name, 'asha') eq 1
Customers?$filter=startswith(Name, 'S')
Customers?$filter=substring(Name, 1, 1) eq substring(Name, 4)
Customers?$filter=tolower(Name) eq 'sasha'
Customers?$filter=toupper(Name) eq 'SASHA'
Customers?$filter=trim(concat(Name, ' ')) eq trim(Name)
Customers(Country='RU',Id=1)
Orders(1)?$expand=Customer,Items
Orders(1)/Items?$filter=Count ge 2
OrderItems(1)/Order/Customer
OrderItems(1)/Order?$apply=groupby((CustomerId), aggregate(Status with min as min))
Orders(1)/Items?$orderby=Count,Price
OrderItems?$orderby=Id desc,Count desc,Price desc
OrderItems?$orderby=Order/Customer/Sex desc,Order/Customer/Name,Id desc
Orders?$filter=AltCustomerId eq 3 and CustomerId eq 4 and ((year(Date) eq 2016 and month(Date) gt 11 and day(Date) lt 20) or Date eq null) and contains(Name,'unknown') and Status eq OdataToEntity.Test.Model.OrderStatus'Unknown'
&$expand=Items($filter=(Count eq 0 or Count eq null) and (Price eq 0 or Price eq null) and (contains(Product,'unknown') or contains(Product,'null')) and OrderId gt -1 and Id ne 1)
Orders?$select=AltCustomer,AltCustomerId,Customer,CustomerId,Date,Id,Items,Name,Status&$orderby=Id
Orders?$select=Name
Customers
Customers?$orderby=Id&$top=3&$skip=2
Orders?$expand=Items&$count=true&$top=1
OrderItems?$filter=OrderId eq 1&$count=true&$top=1


Примеры;
HTTP сервис test/OdataToEntityCore.Asp/OdataToEntity.Test.AspServer
HTTP Mvc сервис test/OdataToEntityCore.Asp/OdataToEntity.Test.AspMvcServer
Клиент Microsoft.OData.Client для HTTP сервиса test/OdataToEntityCore.Asp/OdataToEntity.Test.AspClient
Клиента Microsoft.OData.Client и WCF сервера source/OdataToEntity.Test.Wcf


Пример контракта Wcf сервиса, который работает с Microsoft.OData.Client


[ServiceContract]
public interface IOdataWcf
{
   [OperationContract]
   Task<Stream> Get(String query, String acceptHeader);
   [OperationContract]
   Task<OdataWcfPostResponse> Post(OdataWcfPostRequest request);
}

[MessageContract]
public sealed class OdataWcfPostRequest
{
   [MessageHeader]
   public String ContentType { get; set; }
   [MessageBodyMember]
   public Stream RequestStream { get; set; }
}

[MessageContract]
public sealed class OdataWcfPostResponse
{
   [MessageBodyMember]
   public Stream ResponseStream { get; set; }
}

Скрипт создания базы SQL Server для тестов test\OdataToEntity.Test.EfCore.SqlServer\script.sql
Скрипт создания базы PostgreSql для тестов test\OdataToEntity.Test.EfCore.PostgreSql\script.sql


Исходный код
Nuget пакеты

Tags:
Hubs:
+17
Comments 3
Comments Comments 3

Articles