Реализация Web API OData в ASP.NET Core 3 и ASP.NET 5 (часть 1). Связи «многие-ко-многим»

от автора

Наша команда занимается развитием корпоративной системы электронного документооборота. В команде часть приложений разрабатывается на текущей LTS версии .NET Core 3.1, в частности, бэкэнд для SPA, а также ряд Worker Service’ов, которые с определенным интервалом взаимодействуют с СЭД.

Со временем, возникла необходимость использования этими приложениями общих мастер-данных. Для их хранения решили использовать БД PostgreSQL, так как имели свежий опыт и почти готовое окружение для его развертывания. Непосредственно для получения (а в будущем – и для записи) данных приложениями, решили реализовать Web API на .NET Core 3.1, чтобы инкапсулировать взаимодействие с БД в одном приложении и заложить возможность взаимодействия с любой системой. В качестве ORM, исходя из сложившихся практик и опыта, использовали EF Core. При этом, нужна была возможность фильтрации и получения связанных данных. Чтобы не придумывать велосипед в этой части, пришли к необходимости реализации API на основе стандартов OData.

В сети есть ряд хороших статей по реализации API OData на .NET Core, однако информация в них весьма разрозненна и зачастую авторы упускают важные нюансы, имеющиеся в реализации. В первой статье нами описана общая реализация API OData с использованием EF Core. Особое внимание при этом уделено неочевидным моментам при реализации типа связи «многие-ко-многим».

Реализация на ASP.NET Core 3.1

Вначале в Visual Studio 2019 создадим проект по шаблону ASP.NET Core Web API. Для взаимодействия с БД Postgres в проект добавим пакеты Microsoft.EntityFrameworkCore, Npgsql.EntityFrameworkCore.PostgreSQL, а также, для применения рекомендованного для Postgres нэйминга объектов БД, используем пакет EFCore.NamingConventions. Для реализации требований OData добавляем пакет Microsoft.AspNetCore.OData:

  <ItemGroup>     <PackageReference Include="EFCore.NamingConventions" Version="1.1.1" />     <PackageReference Include="Microsoft.AspNetCore.OData" Version="7.5.6" />     <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.11" />     <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.11">       <PrivateAssets>all</PrivateAssets>       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>     </PackageReference>     <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.11" />   </ItemGroup>

Основная проблема с типом связи «многие-ко-многим» заключалась в том, что EF Core 3.1 не умеет самостоятельно создавать таблицу, связывающую два справочника. Эта опция доступна только в версиях под .NET Framework, либо начиная с .NET 5.

Для примера реализуем модель для справочников Систем и Шаблонов загружаемых файлов: каждая система может использовать несколько шаблонов загрузки, а каждый шаблон может быть использован в нескольких системах. Каждый из классов модели имеет навигационное свойство, указывающее на другую модель. Навигационные свойства обязательно инициализируются в конструкторе класса пустым списком.

// Базовый класс public class BaseDictionaryEntry {     [Column(Order = 1)]     [Key]     public long Id { get; set; }      public string Name { get; set; }      public string Description { get; set; } }  // Модель записи справочника использующих систем public class UsingSystem : BaseDictionaryEntry {     public List<UploadTemplate> UploadTemplates { get; set; }      public UsingSystem()     {         UploadTemplates = new List<UploadTemplate>();     } }  // Модель записи справочника шаблонов загрузки public class UploadTemplate : BaseDictionaryEntry {     public string ProcessName { get; set; }      public List<UsingSystem> UsingSystems { get; set; }      public UploadTemplate()     {         UsingSystems = new List<UsingSystem>();     } }

Контекст БД определим следующим образом:

// Контекст БД public class MyDbContext : DbContext {     public virtual DbSet<UploadTemplate> UploadTemplates { get; set; }      public virtual DbSet<UsingSystem> UsingSystems { get; set; }      public MyDbContext(DbContextOptions<MyDbContext> options)         : base(options)     {     }      public MyDbContext()     {     } }

Startup.cs будет выглядеть следующим образом:

// Startup public class Startup {     public IConfiguration Configuration { get; }          public Startup(IConfiguration configuration)     {         Configuration = configuration;     }          public void ConfigureServices(IServiceCollection services)     {         // Конфигурируем контекст БД         services.AddDbContext<MyDbContext>(options => options             .UseNpgsql(Configuration.GetValue<string>("ConString"),                 assembly => assembly.MigrationsAssembly(typeof(MyDbContext).Assembly.FullName))             .UseSnakeCaseNamingConvention());          services.AddControllers();          // Конфигурируем OData         services.AddOData();     }      public void Configure(IApplicationBuilder app, IWebHostEnvironment env)     {         if (env.IsDevelopment())         {             app.UseDeveloperExceptionPage();         }          app.UseRouting();         app.UseAuthorization();         app.UseEndpoints(endpoints =>         {             endpoints.MapControllers();             endpoints.Select().Filter().OrderBy().Count().MaxTop(10).Expand();             // Добавляем пути OData             endpoints.MapODataRoute("odata", "odata", GetEdmModel());         });     }      // Настройка модели OData     private IEdmModel GetEdmModel()     {         var odataBuilder = new ODataConventionModelBuilder();         odataBuilder.EntitySet<UsingSystem>("UsingSystems");         odataBuilder.EntitySet<UploadTemplate>("UploadTemplates");          return odataBuilder.GetEdmModel();     } }

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

// Контроллер public class UsingSystemsController : BaseDictionaryController {     public UsingSystemsController(DbContext dbContext) : base(dbContext)     {     }      [EnableQuery]     public IActionResult Get()     {         return Ok(_dbContext.UsingSystems             .Include(x => x.UploadTemplates));     }      [EnableQuery]     public IActionResult Get(long key)     {         return Ok(_dbContext.UsingSystems             .Where(x => x.Id == key)             .Include(x => x.UploadTemplates));     } }

После запуска проекта получим следующее исключение:

System.InvalidOperationException: Unable to determine the relationship represented by navigation property ‘UsingSystem.UploadTemplates’ of type ‘List’. Either manually configure the relationship, or ignore this property using the ‘[NotMapped]’ attribute or by using ‘EntityTypeBuilder.Ignore’ in ‘OnModelCreating’.

Это означает, что Entity Framework Core не может понять, как ему связать две наших модели.

Решение проблемы со связью «многие-ко-многим» для .NET Core 3.1

Создавать связующую таблицу придется самостоятельно, определив отдельный класс. В классах UsingSystem и UploadTemplates, необходимо переписать навигационное свойство и его инициализацию пустым списком. Модель и контекст БД теперь выглядят так:

// Базовый класс public class BaseDictionaryEntry {     [Column(Order = 1)]     [Key]     public long Id { get; set; }      public string Name { get; set; }      public string Description { get; set; } }  // Модель записи справочника использующих систем public class UsingSystem : BaseDictionaryEntry {     public List<UploadTemplateUsingSystem> UploadTemplateUsingSystems { get; set; }      public UsingSystem()     {         UploadTemplateUsingSystems = new List<UploadTemplateUsingSystem>();     } }  // Модель записи справочника шаблонов загрузки public class UploadTemplate : BaseDictionaryEntry {     public string ProcessName { get; set; }      public List<UploadTemplateUsingSystem> UploadTemplateUsingSystems { get; set; }      public UploadTemplate()     {         UploadTemplateUsingSystems = new List<UploadTemplateUsingSystem>();     } }  // Модель для связывания сиситем и шаблонов public class UploadTemplateUsingSystem {     public long UploadTemplateId { get; set; }      public UploadTemplate UploadTemplate { get; set; }      public long UsingSystemId { get; set; }      public UsingSystem UsingSystem { get; set; } }  // Контекст БД public class MyDbContext : DbContext {     public virtual DbSet<UploadTemplate> UploadTemplates { get; set; }      public virtual DbSet<UsingSystem> UsingSystems { get; set; }      public virtual DbSet<UploadTemplateUsingSystem> UploadTemplateUsingSystems { get; set; }      public MyDbContext(DbContextOptions<MyDbContext> options)         : base(options)     {     }      public MyDbContext()     {     }      protected override void OnModelCreating(ModelBuilder modelBuilder)     {         modelBuilder.Entity<UploadTemplateUsingSystem>()             .HasKey(x => new {x.UploadTemplateId, x.UsingSystemId});         modelBuilder.Entity<UploadTemplateUsingSystem>()             .HasOne(x => x.UploadTemplate)             .WithMany(x => x.UploadTemplateUsingSystems)             .HasForeignKey(x => x.UploadTemplateId);         modelBuilder.Entity<UploadTemplateUsingSystem>()             .HasOne(x => x.UsingSystem)             .WithMany(x => x.UploadTemplateUsingSystems)             .HasForeignKey(x => x.UsingSystemId);     } }

Обратите внимание, что в связывающем классе следует определить свойства как для хранения внешнего ключа, так и для хранения самого объекта. Причем о том, что UploadTemplateUsingSystem имеет составной ключ, нужно сообщить и Edm модели. Это необходимо, чтобы использовать параметр $expand при обращении к нашему API. Метод GetEdmModel в Startup изменим следующим образом:

    private IEdmModel GetEdmModel()     {         var odataBuilder = new ODataConventionModelBuilder();         odataBuilder.EntitySet<UsingSystem>("UsingSystems");         odataBuilder.EntitySet<UploadTemplate>("UploadTemplates");         odataBuilder.EntityType<UploadTemplateUsingSystem>()             .HasKey(x => new {x.UploadTemplateId, x.UsingSystemId});          return odataBuilder.GetEdmModel();     } 

Изменятся и методы контроллера:

    [EnableQuery]     public IActionResult Get()     {         return Ok(_dbContext.UsingSystems             .Include(x => x.UploadTemplateUsingSystems)             .ThenInclude(x => x.UploadTemplate));     }          [EnableQuery]     public IActionResult Get(long key)     {         return Ok(_dbContext.UsingSystems             .Where(x => x.Id == key)             .Include(x => x.UploadTemplateUsingSystems)             .ThenInclude(x => x.UploadTemplate));     }

После применения миграций в базе появится третья таблица с двумя полями – соответствующими внешними ключами. Кажется, проблема решена. Но давайте попробуем вытащить первую систему и из нее получить список разрешенных шаблонов. Вызовем наше API с параметром $expand следующим запросом:

GET http://localhost:61268/odata/UsingSystems(1)?$expand=UploadTemplates

OData выдаст ошибку, потому что не сможет найти навигационное свойство UploadTemplates в типе UsingSystem. Запрос нужно поправить следующим образом:

GET http://localhost:61268/odata/UsingSystems(1)?$expand= UploadTemplateUsingSystems($expand=UploadTemplates)

В ответ мы получим JSON следующего вида:

{     "@odata.context": "http://myAPI.com/odata/$metadata#UsingSystems(UploadTemplateUsingSystems(UploadTemplate()))",     "value": [         {             "Id": 1,             "Name": "Система1",             "Description": "Просто система",             "UploadTemplateUsingSystems": [                 {                     "UsingSystemId": 1,                     "UploadTemplateId ": 123,                     "UploadTemplate": {                         "Id": 123,                         "Name": "Шаблон1",                         "Description": "Просто шаблон",                         "ProcessName": "Процесс1"                     }                 }             ]         }     ] } 

Видим, что идентификаторы передаются дважды, да и навигация по такому объекту усложняется. Это значит, что на вызывающей стороне также придется усложнять вызов API.

Обновление проекта до .NET 5

Готовый проект пришлось перетаскивать на .NET 5. Для этого мы изменили файл проекта следующим образом:

  <PropertyGroup>     <TargetFramework>net5.0</TargetFramework>     <!-- ... -->   </PropertyGroup>  	<!-- ... -->    <ItemGroup>   <!-- ... -->     <PackageReference Include="EFCore.NamingConventions" Version="5.0.2" />     <PackageReference Include="Microsoft.AspNetCore.OData" Version="8.0.0-preview3" />     <PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.2" />     <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.2">       <PrivateAssets>all</PrivateAssets>       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>     </PackageReference>     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.2" />     <!-- ... -->   </ItemGroup> 

Модель, контекст БД и контроллеры вернули в тот вид, в котором они были приведены в начале статьи. А вот в Startup с переходом к 8 версии (превью) Microsoft.AspNetCore.OData разрешенные методы манипуляции переехали в метод ConfigureServices:

public class Startup {     public IConfiguration Configuration { get; }      public Startup(IConfiguration configuration)     {         Configuration = configuration;     }      public void ConfigureServices(IServiceCollection services)     {         services.AddDbContext<MyDbContext>(options => options             .UseNpgsql(Configuration.GetValue<string>("ConStrings:Mdm"),                 assembly => assembly.MigrationsAssembly(typeof(MyDbContext).Assembly.FullName))             .UseSnakeCaseNamingConvention());         services.AddControllers();          // OData         services.AddOData(opt => opt             .AddModel("odata", GetEdmModel())             .Select()             .Filter()             .OrderBy()             .Count()             .Expand()         );     }        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)     {         if (env.IsDevelopment())         {             app.UseDeveloperExceptionPage();         }          app.UseRouting();         app.UseAuthorization();         app.UseEndpoints(endpoints => { endpoints.MapControllers(); });     }      private IEdmModel GetEdmModel()     {         var odataBuilder = new ODataConventionModelBuilder();         odataBuilder.EntitySet<UsingSystem>("UsingSystems");         odataBuilder.EntitySet<UploadTemplate>("UploadTemplates");          return odataBuilder.GetEdmModel();     } } 

При применении миграций в базе создалась третья таблица для связывания. Результирующий JSON при выполнении запроса

GET http://localhost:61268/odata/UsingSystems(1)?$expand=UploadTemplates

стал выглядеть так:

{     "@odata.context": "http://myAPI.com/odata/$metadata#UsingSystems(UploadTemplates())",     "value": [         {             "Name": "Система1",             "Description": "Просто система",             "Id": 1,             "UploadTemplates": [                 {                     "Id": 123,                     "Name": "Шаблон1",                     "Description": "Просто шаблон",                     "ProcessName": "Процесс1"                 }             ]         }     ] } 

Таким образом, реализация Entity FrameworkCore для .NET 5 позволила нам не только избавиться от ручного создания таблиц связей, но и упростить EDM модель и облегчить взаимодействие с OData на стороне клиента. Поэтому для создания подобных решений считаем .NET 5+ более предпочтительным выбором, чем .NET Core 3.1.

ссылка на оригинал статьи https://habr.com/ru/company/alfastrah/blog/568414/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *