Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Наша команда занимается развитием корпоративной системы электронного документооборота. В команде часть приложений разрабатывается на текущей 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.