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