Сетевой дневник одного программиста

Персональный блог Константина Огородова

Entity Framework Migrations VS FluentMigrator

По мере развития программного продукта изменяется не только код, но и структура БД. Для этого хотелось бы иметь лёгкий в освоении инструмент на все случаи жизни. Наиболее любопытным мне видятся 2 варианта: Миграции Entity Framework и FluentMigrator. Каждый из инструментов по-своему интересен, однако «на двух стульях не усидеть» — нужно выбрать что-то одно. Чтобы было легче определиться я решил поиграться с простеньким решением реализующим создание и обновление БД SqLite используя оба вышеупомянутых инструмента. По итогу проанализирую полученные результаты и на основании этого сделаю выбор. Кстати, в 2019 году компания Cross Technologies озадачилась похожим вопросом, но с большим количеством участников, а результаты своих изысканий, в основном теоретических, опубликовала на Хабре в статье «Сравнение и выбор систем миграции данных» — рекомендую ознакомиться.

Итак, первым делом был создан проект DalEf с двумя сущностями Company и Employee, а также контекстом базы данных MyDbContext:

public class Company
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Address { get; set; }
    public string Country { get; set; }
}
public class Employee
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
    public string Position { get; set; }
    public Company Company { get; set; }
}
public class MyDbContext : DbContext
{
    private readonly string _connectionString;

    public MyDbContext() : base()
    {
        _connectionString = "Data Source = NotExistedDb.sqlite3";
    }

    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options)
    { 
    }

    public DbSet<Company> Companies { get; set; }
    public DbSet<Employee> Employees { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if(_connectionString != null)
            optionsBuilder.UseSqlite(_connectionString);

        base.OnConfiguring(optionsBuilder);
    }
}

Странные «танцы с бубном» вокруг строки подключения связаны с миграцией Entity Framework — этот подход требует явно определить тип используемой БД и один из вариантов это сделать — через переопределение метода OnConfiguring. Однако поскольку я не хочу строку подключения определять именно здесь, то для использования с контейнером внедрения зависимостей определён второй конструктор без инициализации строки подключения.
Также отмечу, что миграции Entity Framework достаточно плотно связаны с проектом содержащим контекст базы данных. И хоть теоретическая возможность использовать отдельный проект для миграций существует, мне не удалось её «завести» за короткий промежуток времени. Конечно потратив больше времени подход был бы найден, но стоит ли оно того? Тем более что возможность исключения некоторой сущности из миграции делается также через контекст базы данных. Короче говоря: при использовании миграций Entity Framework контекст базы данных и сами миграции тесно связаны и лучше оставить их «жить» в одном проекте.
Теперь что касается запуска миграций из кода:

using DbMigrationsComparison.DalEf;
using DbMigrationsComparison.DalEf.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace DbMigrationsComparison.EntryPointFluentMigrator
{
    public static class Programm
    {
        const string ConnectionString = "Data Source = DataEntityFramework.sqlite3";

        public static void Main()
        {
            var services = new ServiceCollection();
            ConfigureServices(services);
            var serviceProvider = services.BuildServiceProvider();
            MigrateDb(serviceProvider);
            InsertTestData(serviceProvider.GetRequiredService<MyDbContext>());

            Console.WriteLine("Бд создана");
        }

        static void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<MyDbContext>(o => o.UseSqlite(ConnectionString));
        }

        static void MigrateDb(ServiceProvider serviceProvider)
        {
            using var scope = serviceProvider.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
            db.Database.Migrate();
        }

        static void InsertTestData(MyDbContext dbContext)
        {
            var company = new Company { Name = "ООО \"В гостях у сказки\"", Address = "Гиблая топь", Country = "Тридевятое царство" };
            var employee = new Employee { Name = "баба Яга", Age = 380, Company = company, Position = "Директрисса" };

            dbContext.Companies.Add(company);
            dbContext.Employees.Add(employee);
            dbContext.SaveChanges();
        }
    }
}

В классе выше много строк кода, но наибольший интерес представляют методы ConfigureServices и MigrateDb, который на самом деле просты и понятны. Для FluentMigrator метод ConfigureServices будет чутка больше:

        static void ConfigureServices(IServiceCollection services)
        {
            // EntityFramework
            services.AddDbContext<MyDbContext>(o => o.UseSqlite(ConnectionString));

            // FluentMigrator
            services
                .AddFluentMigratorCore()
                .ConfigureRunner(c => c.AddSQLite()
                    .WithGlobalConnectionString(ConnectionString)
                    .ScanIn(typeof(InitialSetup).Assembly).For.Migrations());
        }

        static void MigrateDb(ServiceProvider serviceProvider)
        {
            using var scope = serviceProvider.CreateScope();
            var migrationService = scope.ServiceProvider.GetRequiredService<IMigrationRunner>();
            migrationService.MigrateUp();
        }

Но конечно это не критично и пока что для обоих подходов всё в меру лаконично и понятно.
Теперь посмотрим на сами миграции. Вот как выглядят миграции созданные с помощью Entity Framework:

public partial class InitialSetup : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Companies",
            columns: table => new
            {
                Id = table.Column<Guid>(type: "TEXT", nullable: false),
                Name = table.Column<string>(type: "TEXT", nullable: false),
                Address = table.Column<string>(type: "TEXT", nullable: false),
                Country = table.Column<string>(type: "TEXT", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Companies", x => x.Id);
            });

        migrationBuilder.CreateTable(
            name: "Employees",
            columns: table => new
            {
                Id = table.Column<Guid>(type: "TEXT", nullable: false),
                Name = table.Column<string>(type: "TEXT", nullable: false),
                Age = table.Column<int>(type: "INTEGER", nullable: false),
                Position = table.Column<string>(type: "TEXT", nullable: false),
                CompanyId = table.Column<Guid>(type: "TEXT", nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Employees", x => x.Id);
                table.ForeignKey(
                    name: "FK_Employees_Companies_CompanyId",
                    column: x => x.CompanyId,
                    principalTable: "Companies",
                    principalColumn: "Id",
                    onDelete: ReferentialAction.Cascade);
            });

        migrationBuilder.CreateIndex(
            name: "IX_Employees_CompanyId",
            table: "Employees",
            column: "CompanyId");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(
            name: "Employees");

        migrationBuilder.DropTable(
            name: "Companies");
    }
}

Как-то многовато всего… Всё потому, что код автоматически создаётся и поэтому явно указаны имена параметров и форматирование выполнено определённым образом. Если бы код писал человек, то выглядел бы он примерно так:

public partial class InitialSetup : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable("Companies", table => new
        {
            Id = table.Column<Guid>("TEXT"),
            Name = table.Column<string>("TEXT"),
            Address = table.Column<string>("TEXT"),
            Country = table.Column<string>("TEXT")
        },
        constraints: table => table.PrimaryKey("PK_Companies", x => x.Id));

        migrationBuilder.CreateTable("Employees", table => new
        {
            Id = table.Column<Guid>("TEXT"),
            Name = table.Column<string>("TEXT"),
            Age = table.Column<int>("INTEGER"),
            Position = table.Column<string>("TEXT"),
            CompanyId = table.Column<Guid>("TEXT")
        },
            constraints: table =>
            {
                table.PrimaryKey("PK_Employees", x => x.Id);
                table.ForeignKey("FK_Employees_Companies_CompanyId", x => x.CompanyId, "Companies", "Id", onDelete: ReferentialAction.Cascade);
            });

        migrationBuilder.CreateIndex("IX_Employees_CompanyId", "Employees", "CompanyId");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable("Employees");
        migrationBuilder.DropTable("Companies");
    }
}

Однако это даже не рядом с вариантом FluentMigrator:

[Migration(202203101622)]
public class InitialSetup : AutoReversingMigration
{
    public override void Up()
    {
        Create.Table("Companies")
            .WithColumn("Id").AsGuid().NotNullable().PrimaryKey()
            .WithColumn("Name").AsString(50).NotNullable()
            .WithColumn("Address").AsString(60).NotNullable()
            .WithColumn("Country").AsString(50).NotNullable();

        Create.Table("Employees")
            .WithColumn("Id").AsGuid().NotNullable().PrimaryKey()
            .WithColumn("Name").AsString(50).NotNullable()
            .WithColumn("Age").AsInt32().NotNullable()
            .WithColumn("Position").AsString(50).NotNullable()
            .WithColumn("CompanyId").AsGuid().NotNullable().ForeignKey("Companies", "Id");
    }
}

AutoReversingMigration позволяет создавать операции отката на основании обновления. Работает это не для всех операций, но для большинства операций создания — работает. Это, вкупе с определённым синтаксисом и форматированием позволяет получить максимально короткую запись. Однако, поскольку миграция создаётся вручную — есть вероятность что-нибудь пропустить. Например в листинге выше отсутствует индекс по полю CompanyId в таблице Employees, что приведёт к полному сканированию таблицы в случае загрузки сотрудников конкретной компании. Entity Framework упомянутый выше индекс создаёт, даже несмотря на отсутствие свойства Employees в сущности Company. Это безусловно плюсик для Entity Framework по сравнению с FluentMigrator. На всякий случай отмечу, что сущности и сопоставление FluentMigrator я взял из статьи Dapper Migrations with FluentMigrator and ASP.NET Core. Второй плюс Entity Framework получает за скорость разработки — кода много, он не очень симпатичный, но читабельный и создаётся практически мгновенно. Намного проще проверить и при необходимости исправить созданный код, чем писать с нуля. FluentMigrator в свою очередь получает плюс за лаконичность и читабельность кода, а также отсутствие иных файлов участвующих в миграции, что может быть ключевым фактором при разрешении конфликтов слияния. Напомню, что Entity Framework для работы помимо самих миграций хранит ещё и снимок контекста модели БД для которого создавалась последняя миграция.

Теперь самое интересное — результаты работы:

Обратите внимание, что типы данных идентификаторов отличаются. С одной стороны это выглядит логичным, поскольку для FluentMigrator явно указано AsGuid. С другой стороны — типа UNIQUEIDENTIFIER не существует среди типов поддерживаемых SqLite. И несмотря на то, что SqLite использует динамическое типизирование, мне кажется было бы правильным со стороны FluentMigrator либо транслировать Guid в Blob или Text, как это делает EF, либо кидать исключение. Кстати, поскольку EF создаёт миграции на основе контекста БД, то именно через контекст можно повлиять на миграцию. Например можно определить тип для хранения: modelBuilder.Entity().Property(o => o.SomeProperty).HasColumnType("ColumnType");
Дальше больше: решено было добавить свойство SomeOtherProperty к сущности Company, а следующей миграцией удалить это свойство. Оба инструмента новое свойство успешно добавили, однако Fluentmigrator его не удалил и исключения не выбросил, а сделал вид, что всё прошло успешно. Замалчивание исключений — это в большинстве случаев очень и очень плохое решение, которое может применяться в отдельных случаях, например в микропрограммах кардиостимуляторов или космических аппаратов. Но в данном случае программа должна «кричать», что столбец не был удалён. Такое поведение вызвано тем, что некоторое время назад SqLite не поддерживал удаление столбцов, однако сейчас этот функционал поддерживается. Поскольку FluentMigrator это не коммерческий проект энтузиастов — можно сделать скидку на отсутствие быстрой реакции на расширение функционала SqLite. Однако замалчивание исключений это не оправдывает, так что за это FluentMigrator получает жирнющий минус.

Что в итоге? — если доступ к БД планируется организовать с помощью EntityFramework, то этот же инструмент имеет смысл использовать для миграций БД, потому что это быстро и просто, а также гарантирует согласованное состояние между моделью данных и схемой БД. Однако при всём при этом не стоит забывать о проблемах в случае возникновения конфликтов слияния, так что лучше стараться вносить изменения последовательно.
Если же по каким-то причинам EF не будет использоваться — можно присмотреться к FluentMigrator, но его замалчивание исключений вызывает наибольшее беспокойство. Да и организация ссылок внутри мне кажется как минимум странной. В общем текущая версия оставляет желать лучшего, поэтому не могу рекомендовать к использованию в серьёзных проектах. Быть может имеет смысл присмотреться к DbUp, но писать скрипты миграций на чистом SQL без привязки к контекстной модели — то ещё «удовольствие». Зато 100% однозначность — что написал, то и получил, без промежуточных прослоек с синтаксическим «сахаром».

Entity Framework Migrations VS FluentMigrator

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Пролистать наверх