В EF Core много полезных фич по работе с базами данных, но что, если этих возможностей не хватает? Я был удивлен, когда узнал, что фреймворк из коробки не умеет создавать вьюшки и отслеживать изменения их исходного кода. А что, если нам нужны не только вьюшки, но еще и синонимы, гранты и DB link? При этом мы хотим видеть их как на производственной БД, так и в интеграционных тестах! В посте будет инфа про загадочный внутренний мир фреймворка: про ключевые интерфейсы, отвечающие за генерацию и применение миграций, про то, как можно подменить эти интерфейсы, и, самое главное, почему тут не поможет контейнер, создаваемый в Startup. Также поговорим про основные объекты EF Core: что такое модель и зачем нужен снепшот? Из чего состоит миграция и зачем нужно транслировать операции в SQL?

Пост будет интересен как тем разрабам, которые столкнулись с задачами создания и обновления вьюх, синонимов и других SQL-объектов (они узнают про наш пакет, позволяющий закрыть эти вопросы), так и тем, кто хочет написать свое расширение (они узнают про подмену сервисов). Если Вы хотите, чтобы мир EF Core стал для вас менее загадочным, но ничуть не менее интересным, добро пожаловать под кат ↓


Проблема

В работе над одним из проектов мы столкнулись, казалось бы, с тривиальной задачей: в БД нужна вьюха. Что может быть проще?

public partial class View1 : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Создадим вьюху
        migrationBuilder.Sql(
            "create or replace view my_schema.my_view as select * from my_schema.my_table;");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("drop view my_schema.my_view;");
    }
}

Хорошо, но что, если мы хотим отслеживать историю изменений вьюхи? Давайте сделаем константу, в которой сохраним исходники вьюхи, и во всех миграциях будем ссылаться на нее.

public class MyView
{
    // Константа
    public const string View =
        "create or replace view my_schema.my_view as select * from my_schema.my_table;";
}

public partial class Meetup1 : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Теперь используем константу
        migrationBuilder.Sql(MyView.View);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql("drop view my_schema.my_view;");
    }
}

Подождите, но теперь все миграции будут ссылаться на текущую (последнюю) версию вьюхи, а это уже проблема. Пусть в первой миграции мы создаем таблицу и вьюху, а во второй — добавляем колонку в оба объекта. Если на прод всё это поедет в одной версии, то в первой миграции будет попытка создать вьюху, которая ссылается на колонку, которая еще не существует! В некоторых БД такое сработает, а в некоторых — нет. Плюс с таким подходом теряется возможность установить конкретную версию модели на БД (там все равно окажется последняя версия вьюхи, а не та, которая была в момент написания миграции).

Давайте сформулируем все требования, которые у нас есть по использованию вьюх в EF Core:

  • мы хотим уметь отслеживать изменения вьюхи (кто, когда и зачем там что-либо менял);

  • миграции формируются стандартным образом (migrations add), т. е. EF Core «видит» появление, изменение или удаление вьюх и сам генерит UP и DOWN миграции;

  • в БД вьюхи должны попадать тем способом, который хочет использовать конечный программист:

    – это может быть SQL-скрипт (migrations script),
    – либо обновление БД из командной строки (database update),
    – а также вызов Database.Migrate() из кода,
    – и даже Database.EnsureCreated(), что очень полезно в интеграционных тестах.

Выше мы говорили про вьюхи. Но что принципиально изменится, если вместо вьюхи нам потребуется синоним? Или хранимка? Или любой другой объект, который может быть описан посредством сырого SQL? Верно, ничего не изменится, поэтому будем решать общую задачу, в терминах сырых SQL-объектов.

Теория

Давайте начнем с простого — с объектной модели EF Core. Ключевым понятием фрейморка является модель, которая описывает сущности (Entity Class, см. картинку ниже), их взаимосвязи и маппинг на БД. За создание модели отвечает DbContext. Снепшот (ModelSnapshot) — это своего рода фотография модели в некоторый момент времени. Зачем он нужен?

Источник: Modern Data Access with Entity Framework Core. Автор Holger Schwichtenberg. Глава Database Schema Migrations — 114 с.
Источник: Modern Data Access with Entity Framework Core. Автор Holger Schwichtenberg. Глава Database Schema Migrations — 114 с.

Модель с течением времени меняется. Чтобы знать, какие изменения произошли, EF Core при создании миграции (Add-Migration) сравнивает текущую модель со снепшотом. Миграция описывает некоторый инкремент, который надо совершить, чтобы переместиться из предыдущего снепшота в новую версию модели. Миграция может быть применена к базе данных разными способами, например, при помощи генерации и дальнейшего запуска SQL-скрипта (см. Script-Migration на картинке выше), при помощи непосредственного применения (Update-Database). Есть и другие способы. При создании миграции снепшот обновляется, так что в следующий раз EF Core будет сравнивать модель уже с новым снепшотом. Миграция в понятиях EF Core является атомарной, т. е. она либо полностью применяется, либо не применяется вообще. Кстати, не во всех БД с этим все хорошо, подробности можно узнать в статье про идемпотентность миграций.

Каждая миграция состоит из операций, которые описывают изменение одного из объектов: добавление колонки, изменение названия таблицы и т. д. Операция описывает изменения в БД в терминах C#. Поскольку одно и то же изменение БД у разных вендоров описывается разным SQL-кодом, то требуется трансляция операции в SQL-код.

Расширение модели

Мы хотим, чтобы EF Core отслеживал изменения не только таблиц и их колонок, но еще и каких-то других объектов (в нашем случае — сырых SQL-объектов). По сути, нам надо как-то расширить модель EF Core и заставить фреймворк добавлять в модель, снепшот и миграции что-то еще. Создатели фреймворка хранят инфу обо всем, что не связано с сущностями, в аннотациях. Например, в аннотациях хранятся данные о последовательностях (sequence), функциях БД (DbFunctions) и др. Что же, последуем их примеру.

Покажем, как можно расширить модель на примере SQL объектов. Для описания объекта будем использовать record SqlObject(string Name, string SqlCode). Как кажется, смысл полей предельно ясен. Name — для хранения имени, в БД это имя никак не пробрасывается. Имя необходимо, чтобы пакет понимал, появился ли какой-то новый объект? А может, он был изменен или удален? SqlCode — код для создания и обновления объекта (CREATE OR REPLACE VIEW AS…)

Вернемся к аннотациям. Модель создается внутри DbContext.OnModelCreating

public class MyContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ...
        // Вызываем метод расширения
        modelBuilder.AddSqlObjects(new SqlObject("my_view", "create or update ..."));
    }
}

Добавление наших объектов в аннотации выглядит вот так:

public static void AddSqlObjects(this ModelBuilder modelBuilder, params SqlObject[] objects)
{
    modelBuilder.Model["___SqlObjects"] = objects;
}

Контейнеры EF Core

Выше мы разобрались, как добавить в модель что-то свое. Но как добавить описание этих объектов в снепшот? Как научить EF Core сравнивать наши объекты и определять, нужна ли по ним миграция? Как, в конце концов, добавить в миграцию код, который внесет нужные нам изменения в БД? Начну издалека — разберемся, какие в EF Core есть контейнеры и почему.

При расширении EF надо понимать, что стандартный контейнер (из Startup.ConfigureServices) для работы фреймворка не обязателен: в нем EF Core хранит DbContextOptions, он же используется для создания или освобождения DbContext. Однако делать это при помощи контейнера не обязательно — подойдет любой удобный для программиста способ (например, ручное создание и освобождение DbContext). Стандартный контейнер, таким образом, почти не влияет на работу EF Core, в то время как внутренний контейнер, по сути, определяет поведение фреймворка. Именно он отвечает за резолвинг сервисов, необходимых для работы EF Core (генерация снепшота, миграций и пр.). Этот контейнер, как правило, представляет для программиста черный ящик, это детали реализации.

Наверное, вы не удивитесь, прочитав, что фреймворк работает в двух режимах: Runtime и DesignTime. Однако я был потрясен, узнав, что наполнение контейнеров этих двух режимов отличается! Это логично, но достаточно тяжело для первоначального восприятия.

Runtime — это обычный режим, он используется во время работы приложения: EF Core читает и записывает какие-то данные. DesignTime — это режим работы для создания миграций (когда разработчик работает с EF из командной строки). Интересно, что применение миграций может осуществляться как в DesignTime (database update), так и в Runtime (Database.EnsureCreated() или Database.Migrate()). Очевидно, что в Runtime нужны не все сервисы, от которых зависит DesignTime, поэтому создание и наполнение контейнеров в этих двух режимах отличается. Интересно, что во внутреннем контейнере создается scope каждый раз, когда создается DbContext, при этом scope освобождается (Dispose) вместе с DbContext.

В таблице ниже представлено описание контейнеров.

Стандартный

Внутренний

Runtime

Не обязателен для EF Core. Используется для хранения DbContextOptions и своевременного создания или уничтожения DbContext

Обеспечивает работу EF Core в Runtime (например, здесь есть сервисы, необходимые для выполнения команд, получения connectionString и т. п.)

DesignTime

Обеспечивает работу EF Core в DesignTime (например, генерация миграций, снепшотов и т. д.)

Подмена сервисов

Хорошо, с теорией контейнеров разобрались. Стандартный контейнер оставим «как есть», а во внутреннем нам надо сделать подмену ряда сервисов. Как это осуществить? Начнем с Runtime-контейнера. Функция dbOptionsBuilder.ReplaceService() позволяет заменить один из EF Core сервисов на нашу имплементацию. Метод можно вызвать внутри services.AddDbContext либо DbContext.OnConfiguring. Вот пример:

services.AddDbContext<TestContext>(options =>
{
    options.ReplaceService<IEFCoreService, CustomImplementation>();
});

Но как быть, если наша имплементация сервиса зависит от каких-то кастомных сервисов? Например, CustomImplementation зависит от IMyNewService, а это интерфейс из нашего пакета? EF Core про него ничего не знает, dbOptionsBuilder.ReplaceService не сработает! Один из вариантов здесь — использовать DbContextOptionsBuilder.UseInternalServiceProvider(serviceProvider), где serviceProvider – это сконфигурированный вручную внутренний контейнер EF Core. Главный минус такого способа в том, что serviceProvider надо правильно настроить с нуля (добавить туда все нужные для EF Core сервисы), а это нетривиальная задача. К тому же, при переходе на новую версию EF Core состав контейнера может измениться, и придется изменять код, наполняющий контейнер.

Более красивый способ — использование EF Core Extensions. Мы можем написать реализацию IDbContextOptionsExtension, которая содержит метод ApplyServices(IServiceCollection services). Параметр services здесь — это сервисы внутреннего контейнера. Добавление новых зависимостей больше не проблема:

internal class DbContextServicesExtension : IDbContextOptionsExtension
{
    public void ApplyServices(IServiceCollection services)
    {
        services.AddSingleton<IMyNewService, MyServiceImpl>();
    }
}

А чтобы добавить наше расширение достаточно вызвать:

services.AddDbContext<TestContext>(options =>
{
    ((IDbContextOptionsBuilderInfrastructure)optionsBuilder)
        .AddOrUpdateExtension(new DbContextServicesExtension());
});

Выше дана информация об изменении Runtime контейнера, пришло время рассказать о DesignTime. Там дела обстоят немного по-другому. DesignTime контейнер представляет собой надмножество Runtime контейнера, т. е. он содержит в себе все Runtime-сервисы плюс некоторый дополнительный набор (см. картинку ниже). Подмена и добавление Runtime-сервисов делается стандартными для этого режима способами (см. выше), а вот для подмены и добавления DesignTime-сервисов используется отдельный механизм.

В нашем расширении для изменения сервисов мы использовали реализацию специального интерфейса IDesignTimeServices. Важно, чтобы реализация была размещена в startup assembly (размещение в migrations assembly не допускается):

public class CustomDesignTimeServices : IDesignTimeServices
{
    public virtual void ConfigureDesignTimeServices(IServiceCollection services)
    {
        // заменяем стандартные для EF Core сервисы
        ReplaceBySingleton<IMigrationsCodeGenerator, CustomMigrationsGenerator>(services);

        // добавляем новые сервисы, которые нужны для работы наших имплементаций
        services.AddSingleton<ICustomSnapshotGenerator, SqlObjectsSnapshotGenerator>();
    }

    private static void ReplaceBySingleton<TService, TNewService>( IServiceCollection services)
        where TService : class
        where TNewService : class, TService
    {
        RemoveService<TService>(services);
        services.AddSingleton<TService, TNewService>();
    }

    private static void RemoveService<TService>(IServiceCollection services) where TService : class
    {
        var descriptors = services.Where(s => s.ServiceType == typeof(TService)).ToArray();

        foreach (var descriptor in descriptors)
        {
            services.Remove(descriptor);
        }
    }
}

Другой способ сконфигурировать DesignTime-контейнер — использовать [DesignTimeServicesReference]. Атрибут может быть размещен на startup либо на migrations-сборке. На вход атрибут принимает имя типа, реализующего уже знакомый нам IDesignTimeServices.

Описание некоторых сервисов

В этом разделе перечислены те сервисы, которые мы в нашем расширении подменили во внутреннем контейнере EF Core, а именно:

  • в режиме Runtime:
    (1) IMigrationsModelDiffer — находит отличия двух моделей (нам нужно научиться находить различия в SQL-объектах до и после внесения изменений);
    (2) IMigrationsSqlGenerator — транслирует операции в SQL-код (в нашем случае здесь всё просто, поскольку объекты уже описаны в терминах SQL);

  • в режиме DesignTime:
    (3) ICSharpSnapshotGenerator — генерирует снепшот;
    (4) IMigrationsCodeGenerator — генерирует код миграций;
    (5) ICSharpMigrationOperationGenerator — генерирует код операций внутри миграции.

Место каждого из сервисов в работе EF Core представлено на картинке:

(1) IMigrationsModelDiffer отвечает за определение дифа между снепшотами. Мы расширили его реализацию, научив определять изменения, произошедшие в составе и содержании SQL-объектов. На вход данный интерфейс получает бывшую и целевую модели, на выходе порождает операции миграции (в нашем случае операции создания, изменения и удаления SQL-объектов). Кроме прочего, реализация IMigrationsModelDiffer должна генерировать операции в правильной последовательности (вначале удаление SQL-объектов, затем обычное создание или изменение схемы, затем создание или изменение SQL-объектов). Поэтому там перегружен еще и метод Sort.

(2) IMigrationsSqlGenerator отвечает за трансляцию полученных выше операций в SQL. В нашем случае используются CreateOrUpdateSqlObjectSqlGenerator и DropSqlObjectSqlGenerator: наша трансляция предельно простая — SQL уже готов, просто возвращаем его.

(3) ICSharpSnapshotGenerator отвечает за генерацию снепшотов и содержит единственный метод Generate, который всего-то на всего добавляет нужный код в StringBuilder. Наш CustomSnapshotGenerator генерирует для YourContextModelSnapshot код наподобие:

modelBuilder.Model.AddSqlObjects(new SqlObject[]
{
    new("v_view_10", "create or replace view migr_ext_tests.v_view_10 as select * from migr_ext_tests.my_table;"),
    new("v_view_2.sql", "create or replace view migr_ext_tests.name123_2 as select * from migr_ext_tests.my_table;"),
});

Подмена (4) IMigrationsCodeGenerator нужна, чтобы в класс миграции были добавлены нужные пространства имен. (5) ICSharpMigrationOperationGenerator занимается предельно простой работой — по данным, полученным из (1) генерирует операции миграций. Здесь, как и в случае с генерацией снепшота, ничего сложного нет — обычный StringBuilder. На выходе получаем такие миграции:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateOrUpdateSqlObject(
        name: "v_view_10",
        sqlCode: "create or replace view migr_ext_tests.v_view_10 as select * from migr_ext_tests.my_table;",
        order: 10);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropSqlObject(
        name: "v_view_10",
        sqlCode: DROP v_view_10, // write code to drop object
        order: 10);
}

Как видно, пакет не умеет генерировать код для удаления SQL-объектов, но с этим, думаю, сложностей не будет. Описание такой операции требуется один раз ????

Использование пакета

Мы опубликовали расширение, добавляющее сырые SQL-объекты в EF Core в пакете CUSTIS.NetCore.EF.MigrationGenerationExtensions, который доступен по лицензии Apache-2.0.

Шаги, которые необходимо выполнить для начала использования пакета, описаны на GitHub.

Чтобы добавить SQL-объекты в модель, необходимо вызвать modelBuilder.AddSqlObjects в OnModelCreating. Пакет позволяет добавлять объекты не только из C#-кода, но и из ресурсов, что особенно удобно: в SQL-файлах VisualStudio подсвечивает синтаксис.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // ...
    // Добавляем SQL-объекты из C#-кода
    const string Sql = "create or replace view migr_ext_tests.v_view_10 as select * from migr_ext_tests.my_table;";
    modelBuilder.AddSqlObjects(new SqlObject(Name: "v_view_10", SqlCode: Sql) { Order = 10 });

    // Добавляем SQL-объекты из ресурсов, расположенных в заданной сборке в папке "Sql"
    // Добавляются только ресурсы, имеющие расширение ".sql"
    modelBuilder.AddSqlObjects(assembly: typeof(Class1).Assembly, folder: "Sql");
} 

После добавления SQL-объектов, используйте стандартные способы создания миграций (например, dotnet ef migrations add InitialCreate). В миграцию попадет снимок кода в том виде, в котором SQL-объект существовал при добавлении миграции:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateOrUpdateSqlObject(
        name: "v_view_10",
        sqlCode: "create or replace view migr_ext_tests.v_view_10 as select * from migr_ext_tests.my_table;",
        order: 10);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropSqlObject(
        name: "v_view_10",
        sqlCode: DROP v_view_10, // write code to drop object
        order: 10);
}

Когда понадобится, вносите изменения в SQL-объекты напрямую, EF Core «увидит» ваши изменения при добавлении следующей миграции. В следующую миграцию в UP попадет новая версия кода, а в DOWN будет предыдущая версия:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateOrUpdateSqlObject(
        name: "v_view_10",
        sqlCode: "some new code here;",
        order: 10);
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropSqlObject(
        name: "v_view_10",
        sqlCode: "create or replace view migr_ext_tests.v_view_10 as select * from migr_ext_tests.my_table;",
        order: 10);
}

CUSTIS.NetCore.EF.MigrationGenerationExtensions решает задачи удобства и прозрачности использования SQL-объектов, а именно:

  1. Изменения SQL-объектов вносятся в одном месте (и можно отследить их историю);

  2. SQL-объекты накатываются на БД тем способом, который нужен разработчику.

Будем рады вашим pull-request и использованию пакета!

Послесловие

Изначально идея расширения EF Core возникла у нас, когда в одном из проектов в миграциях понадобились гранты, синонимы, вьюхи и DB link. В том проекте на каждый из этих объектов была создана своя структура, описывающая его, и соответствующий транслятор из терминов .NET в SQL (для Oracle). Вот пример:

/// <summary> Разрешение, выданное одной схемой другой на свой объект </summary>
public record DbGrant(string Grantor, string ObjectName, string Grantee, params DbPrivilege[] Privileges);

/// <summary> Привилегия, которую можно раздать в БД на объект </summary>
public enum DbPrivilege
{
    References,
    Select,
    //...
}

internal sealed class CreateGrantSqlGenerator : SqlGeneratorBase<CreateGrantOperation>
{
    protected override void Generate(
        CreateGrantOperation operation,
        MigrationCommandListBuilder builder,
        InIdempotentWrapper inIdempotentWrapper)
    {
        // GRANT SELECT, INSERT, UPDATE, DELETE ON schema.books TO books_admin
        builder
            .Append("GRANT ")
            .Append(operation.Privileges.JoinByComma())
            .Append(" ON ")
            .Append($"{operation.Grantor}.{operation.ObjectName}")
            .Append(" TO ")
            .AppendLine(operation.Grantee)
            .AppendLine(";")
            .EndCommand();
    }
}

Как видно, в данном случае программисту не требуется писать SQL-код, за него это делает транслятор.

С момента реализации того проекта прошло больше года, и в этом году в другом проекте мы столкнулись с подобной задачей (понадобились вьюхи). Мы решили написать максимально простой пакет, который позволит добавлять любые SQL-объекты в БД. Можно подумать, что пакет не зависит от вендора БД? Не тут-то было ☹️

К сожалению, с точки зрения EF Core наше расширение должно уметь транслировать операции в SQL. Да, в нашем случае это просто возврат готового кода, но фреймворк-то думает, что мы для какой-то конкретной БД это делаем! К счастью, написать свое расширение для любимой БД проще простого. Поэтому ждем pull-request от желающих поддержки своих БД. На текущий момент пакет имеет поддержку только для Postgres.

Подробнее про контейнеры читайте в статье Dependency Injection in EF Core 1.1. Информация про написание своих расширений есть в Расширенной настройке EF Core.

До новых встреч!

Комментарии (1)


  1. hbn3
    15.11.2022 17:54

    В случае интеграции .Net приложения с MS SQL, где есть поддержка CLR, с которой расширение EF может помочь.

    Т.е. можно создать определение интерфейса которое реализует CLR модуль на сервере и расширение EF будет автоматически транслировать вызовы методов этого интерфейса в вызовы CLR методов на сервере. У нас был подобный модуль ещё для EF 4.

    Было бы интересно автоматически генерировать подобный интерфейс для пользовательских функций из DDL и реализовать подобным образом как мы делали для CLR. По идее достаточно просто должно получиться. Пользовательские функции гораздо шире распространены по сравнению с CLR функциями, так что может быть полезно.