Как мы сдружили 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, IUnitOfWork
{
    public MyDbContext(string connectionString) : base(connectionString)
    {
        Database.SetInitializer(new DbInitializer());
        Database.Initialize(true);
    }
}


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


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

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

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


    
    
    
      
      
    



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


    
      
    



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

«Server = localhost; DataBase = TestDB; Integrated Security = false; User Id = postgres; password = pa$$w0rd»

Как и ожидалось, благодаря ручному режиму EF Migrations, инициализация не прошла, и возникла ошибка несоответствия снимка БД текущей модели. Чтобы обойти создание первичной миграции с новым провайдером и протестировать инициализацию БД на Postgres, мы скорректировали немного конфигурацию нашей инфраструктуры.

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

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


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

public class DbInitializer : CreateDatabaseIfNotExists
{
    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=TestDB; 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=TestDB; 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
{
    private string _connectionName;

    public DbInitializer(string connectionName)
    {
        _connectionName = connectionName;
    }

    public override void InitializeDatabase(MyDbContext context)
    {
        DbMigrationsConfiguration 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; MultipleActiveResultSets=True» «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=TestDB; 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=DSS; 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.

Надеемся, вам был интересен наш опыт. Готовы принять замечания и ответить на вопросы, велкам!

© Habrahabr.ru