Как мы сдружили EF 6 с MSSQL и PostgresSQL

    image

    Жил-был проект на EF 6 с СУБД MSSQL. И появилась необходимость добавить возможность его работы с СУБД PostgreSQL. Проблем здесь мы не ожидали, ведь есть большое количество статей на эту тему, и на форумах можно найти обсуждение похожих задач. Однако, на деле не все оказалось так просто, и в этой статье мы расскажем об этом опыте, о проблемах, с которыми мы столкнулись в ходе интеграции нового провайдера, и про выбранное нами решение.

    Вводная


    У нас коробочный продукт, и он имеет уже устоявшуюся структуру. Изначально он был настроен на работу с одной СУБД — MSSQL. Проект имеет слой доступа к данным с реализацией EF 6 (подход Code First). С миграциями работаем через EF 6 Migrations. Миграции создаются в ручном режиме. Первичная установка БД происходит из консольного приложения с инициализацией контекста по строке подключения, передаваемого в качестве аргумента:

    static void Main(string[] args)
    {
        if (args.Length == 0)
        {
            throw new Exception("No arguments in command line");
        }
        var connectionString = args[0];
    
        Console.WriteLine($"Initializing dbcontext via {connectionString}");
        try
        {
            using (var context = MyDbContext(connectionString))
            {
                Console.WriteLine("Database created");
            }
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
            throw;
        }
    }
    

    При этом инфраструктура EF и доменная область описаны в другом проекте, который подключен к консольному приложению в качестве библиотеки. Конструктор контекста в проекте инфраструктуры выглядит так:

    public class MyDbContext : IdentityDbContext<User, Role, Key, UserLogin, UserRole, UserClaim>, IUnitOfWork
    {
        public MyDbContext(string connectionString) : base(connectionString)
        {
            Database.SetInitializer(new DbInitializer());
            Database.Initialize(true);
        }
    }
    

    Первый запуск


    Первое, что мы сделали, — подключили к проекту два пакета через nuget: Npgsql и EntityFramework6.Npgsql.

    А также прописали в App.config нашего консольного приложения настройки для Postgres.

    В секции entityFramework указали в качестве фабрики соединения по умолчанию фабрику postgres:

    <entityFramework>
        <!--<defaultConnectionFactory type="System.Data.Entity.Infrastructure.SqlConnectionFactory, EntityFramework" />-->
        <defaultConnectionFactory type="Npgsql.NpgsqlConnectionFactory, EntityFramework6.Npgsql" />
        <providers>
          <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
          <provider invariantName="Npgsql" type="Npgsql.NpgsqlServices, EntityFramework6.Npgsql" />
        </providers>
    </entityFramework>
    

    В секции DbProviderFactories зарегистрировали фабрику нового провайдера:

    <system.data>
        <DbProviderFactories>
          <add name="Npgsql Data Provider" invariant="Npgsql" support="FF" description=".Net Framework Data Provider for Postgresql" type="Npgsql.NpgsqlFactory, Npgsql" />
        </DbProviderFactories>
    </system.data>
    

    И сразу в лоб попробовали инициализировать БД, указав в строке подключения адрес сервера Postgres и учетные данные админа сервера. Получилась такая строка:
    “Server = localhost; DataBase = TestPostgresDB; Integrated Security = false; User Id = postgres; password = pa$$w0rd”
    Как и ожидалось, благодаря ручному режиму EF Migrations, инициализация не прошла, и возникла ошибка несоответствия снимка БД текущей модели. Чтобы обойти создание первичной миграции с новым провайдером и протестировать инициализацию БД на Postgres, мы скорректировали немного конфигурацию нашей инфраструктуры.

    Во-первых, мы включили “автомиграции” — полезная опция, если изменения доменных моделей и инфраструктуры EF в команде ведет один разработчик:

    public sealed class Configuration : DbMigrationsConfiguration<MyDbContext>
    {
        public Configuration()
        {
            AutomaticMigrationsEnabled = true;
            ContextKey = "Project.Infrastructure.MyDbContext";
        }
    }
    

    Во-вторых, мы указали нового провайдера в переопределенном методе InitializeDatabase унаследованного класса CreateDatabaseIfNotExists, где у нас запускаются миграции:

    public class DbInitializer : CreateDatabaseIfNotExists<MyDbContext>
    {
        public override void InitializeDatabase(MyDbContext context)
        {
            DbMigrator dbMigrator = new DbMigrator(new Configuration
            {
                //TargetDatabase = new DbConnectionInfo(context.Database.Connection.ConnectionString, "System.Data.SqlClient")
                TargetDatabase = new DbConnectionInfo(context.Database.Connection.ConnectionString, "Npgsql")
            });
    
            // There some code for run migrations
        }
    }
    

    Далее, мы снова запустили наше консольное приложение с той же строкой подключения в качестве аргумента. На этот раз инициализация контекста прошла без ошибок, и наши доменные модели благополучно легли в новую БД на Postgres. В новой базе данных появилась табличка “__MigrationHistory”, в которой лежала единственная запись о первой автоматически созданной миграции.

    Подведем подытог: мы без особых проблем смогли подключить нового провайдера к существующему проекту, но при этом изменили настройки механизма миграций.

    Включаем ручной режим миграций


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

    Сначала мы вернули полю AutomaticMigrationsEnabled значение false. Затем надо было разобраться с созданием новых миграций. Мы понимали, что миграции для разных СУБД, как минимум, должны храниться в разных папках проекта. Поэтому мы решили создать новую папку под миграции Postgres в проекте инфраструктуры под названием PostgresMigrations (папку с миграциями MsSql, для наглядности, мы переименовали в MsSqlMigrations), и скопировали в нее конфигурационный файл миграций MsSql. При этом, все существующие миграции MsSql мы не копировали в PostgresSql. Во-первых, потому, что все они содержат снимок конфигурации под провайдера MsSql и, соответственно, мы их не сможем использовать на новом СУБД. Во-вторых, для нового СУБД нам не важна история изменений, и мы можем обойтись последним снимком состояния доменных моделей.

    Мы посчитали, что все готово для формирования первой миграции на Postgres. Удалили БД, созданную при инициализации контекста с включенным режимом автоматических миграций. И, руководствуясь тем, что для первой миграции нужно сформировать физическую БД на основе текущего состояния доменных моделей, мы радостно забили команду Update-Database в Package Manager Console, указав только параметр строки подключения. В итоге мы получили ошибку, связанную с подключением к СУБД.

    Дополнительно изучив принцип работы команды Update-Database, мы сделали следующее:

    • добавили в настройки конфигурации миграций следующий код:

      для MsSql:

      public Configuration()
      {
          AutomaticMigrationsEnabled = false;
          ContextKey = "Project.Infrastructure.MyDbContext";
          MigrationsDirectory = @"MsSqlMigrations";
      }
      

      для Postgres:

      public Configuration()
      {
          AutomaticMigrationsEnabled = false;
          ContextKey = "Project.Infrastructure.MyDbContext";
          MigrationsDirectory = @"PostgresMigrations";
      }
      
    • указали необходимый параметр команды Update-Database, передающий название провайдера
    • добавили параметры, которые указывают на проект, содержащий описание инфраструктуры ef, и на папку с конфигурацией миграций нового провайдера

    В итоге мы получили вот такую команду:
    Update-Database -ProjectName «Project.Infrastructure» -ConfigurationTypeName Project.Infrastructure.PostgresMigrations.Configuration -ConnectionString «Server=localhost; DataBase=TestPostgresDB; Integrated Security=false; User Id=postgres; password=pa$$w0rd» -ConnectionProviderName «Npgsql»
    После выполнения этой команды мы смогли выполнить команду Add-Migration с аналогичными параметрами, назвав первую миграцию InitialCreate:
    Add-Migration -Name «InitialCreate» -ProjectName «CrossTech.DSS.Infrastructure» -ConfigurationTypeName CrossTech.DSS.Infrastructure.PostgresMigrations.Configuration -ConnectionString «Server=localhost; DataBase=TestPostgresDB; Integrated Security=false; User Id=postgres; password=pa$$w0rd» -ConnectionProviderName «Npgsql»
    В папке PostgresMigrations появился новый файл: 2017010120705068_InitialCreate.cs

    Затем мы удалили БД, созданную после выполнения команды Update-Database, и запустили наше консольное приложение со строкой подключения, указанной выше в качестве аргумента. И вот мы получили БД уже на основе миграции, созданной вручную.

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

    Переключение между провайдерами


    У нас оставался один незакрытый вопрос: как настроить инициализацию контекста таким образом, чтобы можно было обращаться к конкретному СУБД в runtime?

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

    В консольном приложении проекта в app.config (а если не использовать app.config, то machine.config) добавляем новую строку подключения с указанием провайдера и названия соединения, а в конструктор контекста «прокидываем» название соединения вместо строки подключения. При этом, саму строку подключения связываем с контекстом через синглтон инстанции DbConfiguration. В качестве параметра передаем инстанцию унаследованного класса от DbConfiguration.

    Получившийся унаследованный класс DbConfiguration:

    public class DbConfig : DbConfiguration
    {
        public DbConfig(string connectionName, string connectionString, string provideName)
        {
            ConfigurationManager.ConnectionStrings.Add(new ConnectionStringSettings(connectionName, connectionString, provideName));
    
            switch (connectionName)
            {
                case "PostgresDbConnection":
                    this.SetDefaultConnectionFactory(new NpgsqlConnectionFactory());
                    this.SetProviderServices(provideName, NpgsqlServices.Instance);
                    this.SetProviderFactory(provideName, NpgsqlFactory.Instance);
                    break;
    
                case "MsSqlDbConnection":
                    this.SetDefaultConnectionFactory(new SqlConnectionFactory());
                    this.SetProviderServices(provideName, SqlProviderServices.Instance);
                    this.SetProviderFactory(provideName, SqlClientFactory.Instance);
                    this.SetDefaultConnectionFactory(new SqlConnectionFactory());
                    break;
            }
        }
    }
    

    И сама инициализация контекста теперь выглядит так:

    var connectionName = args[0];
    var connectionString = args[1];
    var provideName = args[2];
    
    DbConfiguration.SetConfiguration(new DbConfig(connectionName, connectionString, provideName));
    using (var context = MyDbContext(connectionName))
    {
        Console.WriteLine("Database created");
    }
    

    И кто следил внимательно, тот наверняка заметил, что нам оставалось сделать еще одно изменение в коде. Это определение целевой БД во время инициализации БД, которая происходит в описанном ранее методе InitializeDatabase.

    Мы добавили простой switch для определения конфигурации миграций конкретного провайдера:

    public class DbInitializer : CreateDatabaseIfNotExists<MyDbContext>
    {
        private string _connectionName;
    
        public DbInitializer(string connectionName)
        {
            _connectionName = connectionName;
        }
    
        public override void InitializeDatabase(MyDbContext context)
        {
            DbMigrationsConfiguration<MyDbContext> config;
            switch (_connectionName)
            {
                case "PostgresDbConnection":
                    config = new PostgresMigrations.Configuration();
                    break;
                case "MsSqlDbConnection":
                    config = new MsSqlMigrations.Configuration();
                    break;
                default:
                    config = null;
                    break;
            }
            if (config == null) return;
    
            config.TargetDatabase = new DbConnectionInfo(_connectionName);
            DbMigrator dbMigrator = new DbMigrator(config);
    
            // There some code for run migrations
        }
    }
    

    А сам конструктор контекста стал выглядеть так:

    public MyDbContext(string connectionNameParam) : base(connectionString)
    {
        Database.SetInitializer(new DbInitializer(connectionName = connectionNameParam));
        Database.Initialize(true);
    }
    

    Далее, мы запустили консольное приложение и указали в качестве провайдера СУБД в параметре приложение MsSql. Аргументы приложения мы задали следующие:
    «MsSqlDbConnection» «Server=localhost\SQLEXPRESS; Database=TestMsSqlDB; User Id=sa; password=pa$$w0rd» «System.Data.SqlClient»

    База данных MsSql была создана без ошибок.

    Затем мы указали аргументы приложения:
    «PostgresDbConnection» «Server=localhost; DataBase=TestPostgresDB; Integrated Security=false; User Id=postgres; password=pa$$w0rd» «Npgsql»
    База данных Postgres была создана также без ошибок.

    Итак, еще один подытог — для того, чтобы EF мог инициализировать контекст БД для конкретного провайдера, в runtime необходимо:

    • “указать” механизму миграций на этого провайдера
    • сконфигурировать строки подключения к СУБД до инициализации контекста

    Работаем с миграциями двух СУБД в команде


    Как мы увидели, самое интересное начинается после появления новых изменений в домене. Вам необходимо для двух СУБД генерировать миграции с учетом конкретного провайдера.

    Так, для MSSQL Server нужно выполнить последовательные команды (для Postgres описаны команды выше, при создании первой миграции):

    • обновление БД в соответствии с последним снимком
      Update-Database -ProjectName «Project.Infrastructure» -ConfigurationTypeName Project.Infrastructure.MsSqlMigrations.Configuration -ConnectionString «Server=localhost; DataBase=TestMsSqlDB; Integrated Security=false; User Id=sa; password=pa$$w0rd» -ConnectionProviderName «System.Data.SqlClient»
    • добавление новой миграции
      Add-Migration -Name «SomeMigrationName» -ProjectName «Project.Infrastructure» -ConfigurationTypeName Project.Infrastructure.MsSqlMigrations.Configuration -ConnectionString «Server=localhost; DataBase=TestMsSqlDB; Integrated Security=false; User Id=sa; password=pa$$w0rd» -ConnectionProviderName «System.Data.SqlClient»

    Когда разработчики осуществляют изменения в домене параллельно, мы получаем множественные конфликты при слиянии этих изменений в системе контроля версий (для простоты назовем git). Это связано с тем, что миграции в EF идут последовательно друг за другом. И если один разработчик создаст миграцию, то другому разработчику просто добавить последовательно миграцию не получится. Каждая последующая миграция хранит информацию о предыдущей. Таким образом, нужно обновлять так называемые снимки моделей в миграции на последнюю созданную.

    При этом, решение конфликтов по миграциям EF в команде сводится к выставлению приоритетов значимости изменений того или иного разработчика. И чьи изменения выше по приоритету, те и должны первыми заливать их в git, а остальным разработчикам по оговоренной иерархии нужно сделать следующее:

    1. удалить созданные локальные миграции
    2. подтянуть к себе изменения из репозитория, куда остальные коллеги с высоким приоритетом уже влили свои миграции
    3. создать локальную миграцию и залить получившиеся изменения обратно в git

    Насколько мы плотно познакомились с механизмом миграций EF, настолько можем судить о том, что описанный подход командной разработки является единственный на текущий момент. Мы не считаем это решение идеальным, но оно имеет право на жизнь. И для нас стал насущным вопрос поиска альтернативы механизму EF Migrations.

    В заключение


    Работать с несколькими СУБД, применяя EF6 в связке с EF Migrations, реально, но в этом варианте ребята из Microsoft не учли возможность параллельной работы команды с использованием систем контроля версий.

    Есть множество альтернативных EF Migrations решений на рынке (как платных, так и бесплатных): DbUp, RoundhousE, ThinkingHome.Migrator, FluentMigrator и т.д. И, судя по отзывам, они больше по душе разработчикам, нежели EF Migrations.

    К счастью, у нас появилась возможность сделать некий апгрейд в нашем проекте. И в ближайшее время мы будем переходить на EF Core. Мы взвесили все «за» и «против» механизма EF Core Migrations и пришли к выводу, что нам удобнее будет работать со сторонним решением, а именно Fluent Migrator.

    Надеемся, вам был интересен наш опыт. Готовы принять замечания и ответить на вопросы, велкам!
    Cross Technologies
    Системный интегратор и разработчик ПО

    Комментарии 4

      +1
      удалить созданные локальные миграции
      подтянуть к себе изменения из репозитория, куда остальные коллеги с высоким приоритетом уже влили свои миграции
      создать локальную миграцию и залить получившиеся изменения обратно в git

      Это мягко говоря не всегда так. Вернее, так делать может понадобиться только в том случае, если 2 разработчика вливают конфликтующие изменения модели. Во всех остальных случаях достаточно добавить пустую миграцию для корректировки снэпшоты модели в БД. Посмотрите внимательно этот гайд (https://m.habr.com/ru/post/277915/), там описан подход.
      Добавление пустой миграции, имхо, предпочтительнее головняка с пересозданием миграцией, т. к. уменьшается вероятность ошибки при перегенерации (могут быть и ручные правки внутри). Всё пустые миграции с ходом времени можно удалять из кода без последствий (достаточно, чтобы после пустой миграции появилась хотя бы одна новая, которая сохранит актуальный снэпшот модели).

        0
        Вы правы, этот подход с пустой миграцией также можно использовать в совместной работе. Мы как раз руководствовались тем гайдом, который вы упомянули(описанный вариант #2). Мы выбрали второй вариант по причине его универсальности, не надо задумываться о том, существуют ли конфликты в изменениях или нет. По хорошему, нужно использовать конкретный вариант, в соответствии с описанными сценариями.
        –1
        Довелось мигрировать к code first с миграциями, это было как кошмарный сон. Не раз разработчики забывали что нельзя подключаться к продакшену, т.к. изменения уйдут в продакшен. Пока команда привыкла что и как делать прошло пару месяцев. В общем EF палка о двух концах, вроде как облегчает жизнь, но в последствии за это надо платить. Мы потом часть системы перевели на Dapper, часть на NoSQL.
          0
          Довелось мигрировать к code first с миграциями, это было как кошмарный сон. Не раз разработчики забывали что нельзя подключаться к продакшену, т.к. изменения уйдут в продакшен

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

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое