Наша команда занимается развитием корпоративной системы электронного документооборота. В команде часть приложений разрабатывается на текущей 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/
Добавить комментарий