Как мы сдружили EF 6 с MSSQL и PostgresSQL
Жил-был проект на 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, а остальным разработчикам по оговоренной иерархии нужно сделать следующее:
- удалить созданные локальные миграции
- подтянуть к себе изменения из репозитория, куда остальные коллеги с высоким приоритетом уже влили свои миграции
- создать локальную миграцию и залить получившиеся изменения обратно в git
Насколько мы плотно познакомились с механизмом миграций EF, настолько можем судить о том, что описанный подход командной разработки является единственный на текущий момент. Мы не считаем это решение идеальным, но оно имеет право на жизнь. И для нас стал насущным вопрос поиска альтернативы механизму EF Migrations.
В заключение
Работать с несколькими СУБД, применяя EF6 в связке с EF Migrations, реально, но в этом варианте ребята из Microsoft не учли возможность параллельной работы команды с использованием систем контроля версий.
Есть множество альтернативных EF Migrations решений на рынке (как платных, так и бесплатных): DbUp, RoundhousE, ThinkingHome.Migrator, FluentMigrator и т.д. И, судя по отзывам, они больше по душе разработчикам, нежели EF Migrations.
К счастью, у нас появилась возможность сделать некий апгрейд в нашем проекте. И в ближайшее время мы будем переходить на EF Core. Мы взвесили все «за» и «против» механизма EF Core Migrations и пришли к выводу, что нам удобнее будет работать со сторонним решением, а именно Fluent Migrator.
Надеемся, вам был интересен наш опыт. Готовы принять замечания и ответить на вопросы, велкам!