1. 程式人生 > >全自動遷移資料庫的實現 (Fluent NHibernate, Entity Framework Core)

全自動遷移資料庫的實現 (Fluent NHibernate, Entity Framework Core)

在開發涉及到資料庫的程式時,常會遇到一開始設計的結構不能滿足需求需要再新增新欄位或新表的情況,這時就需要進行資料庫遷移。
實現資料庫遷移有很多種辦法,從手動管理各個版本的ddl指令碼,到實現自己的migrator,或是使用Entity Framework提供的Code First遷移功能。
Entity Framework提供的遷移功能可以滿足大部分人的需求,但仍會存在難以分專案管理遷移程式碼和容易出現"context has changed"錯誤的問題。

這裡我將介紹ZKWeb網頁框架在Fluent NHibernate和Entity Framework Core上使用的辦法。
可以做到新增實體欄位後,只需重新整理網頁就可以把變更應用到資料庫。

實現全自動遷移的思路

資料庫遷移需要指定變更的部分,例如新增表和新增欄位。
而實現全自動遷移需要自動生成這個變更的部分,具體來說需要

  • 獲取資料庫現有的結構
  • 獲取程式碼中現有的結構
  • 對比結構之間的差異並生成遷移

這正是Entity Framework的Add-Migration(或dotnet ef migrations add)命令所做的事情,
接下來我們將看如何不使用這類的命令,在NHibernate, Entity Framework和Entity Framework Core中實現全自動的處理。

Fluent NHibernate的全自動遷移

ZKWeb框架使用的完整程式碼可以

檢視這裡

首先Fluent NHibernate需要新增所有實體的對映型別,以下是生成配置和新增實體對映型別的例子。
配置類的結構可以檢視這裡

var db = MsSqlConfiguration.MsSql2008.ConnectionString("連線字串");
var configuration = Fluently.Configure();
configuration.Database(db);
configuration.Mappings(m => {
    m.FluentMappings.Add(typeof(FooEntityMap));
    m.FluentMappings.Add(typeof(BarEntityMap));
    ...
});

接下來是把所有實體的結構新增或更新到資料庫。
NHibernate提供了SchemaUpdate,這個類可以自動檢測資料庫中是否已經有表或欄位,沒有時自動新增。
使用辦法非常簡單,以下是使用的例子

configuration.ExposeConfiguration(c => {
    // 第一個引數 false: 不把語句輸出到控制檯
    // 第二個引數 true: 實際在資料庫中執行語句
    new SchemaUpdate(c).Execute(false, true);
});

到這一步就已經實現了全自動遷移,但我們還有改進的餘地。
因為SchemaUpdate不儲存狀態,每次都要檢測資料庫中的整個結構,所以執行起來EF的遷移要緩慢很多,
ZKWeb框架為了減少每次啟動網站的時間,在執行更新之前還會檢測是否需要更新。

var scriptBuilder = new StringBuilder();
scriptBuilder.AppendLine("/* this file is for database migration checking, don't execute it */");
new SchemaExport(c).Create(s => scriptBuilder.AppendLine(s), false);
var script = scriptBuilder.ToString();
if (!File.Exists(ddlPath) || script != File.ReadAllText(ddlPath)) {
    new SchemaUpdate(c).Execute(false, true);
    onBuildFactorySuccess = () => File.WriteAllText(ddlPath, script);
}

這段程式碼使用了SchemaExport來生成所有表的DDL指令碼,生成後和上次的生成結果對比,不一致時才呼叫SchemaUpdate更新。

NHibernate提供的自動遷移有以下的特徵,使用時應該注意

  • 欄位只會新增,不會刪除,如果你重新命名了欄位原來的欄位也會保留在資料庫中
  • 欄位型別如果改變,資料庫不會跟著改變
  • 關聯的外來鍵如果改變,遷移時有可能會出錯

總結NHibernate的自動遷移只會新增表和欄位,基本不會修改原有的結構,有一定的限制但是比較安全。

Entity Framework的全自動遷移

ZKWeb框架沒有支援Entity Framework 6,但實現比較簡單我就直接上程式碼了。
例子

// 呼叫靜態函式,放到程式啟動時即可
// Database是System.Data.Entity.Database
Database.SetInitializer(new MigrateDatabaseToLatestVersion<MyContext, MyConfiguration>());

public class MyConfiguration : DbMigrationsConfiguration<MyContext> {
    public MyConfiguration() {
        AutomaticMigrationsEnabled = true; // 啟用自動遷移功能
        AutomaticMigrationDataLossAllowed = true; // 允許自動刪欄位,危險但是不加這個不能重新命名欄位
    }
}

Entity Framework提供的自動遷移有以下的特徵,使用時應該注意

  • 如果欄位重新命名,舊的欄位會被刪除掉,推薦做好資料的備份和儘量避免重新命名欄位
  • 外來鍵關聯和欄位型別都會自動變化,變化時有可能會導致原有的資料丟失
  • 自動遷移的記錄和使用工具遷移一樣,都會儲存在__MigrationHistory表中,切勿混用否則程式碼將不能用到新的資料庫中

總結Entity Framework的遷移可以保證實體和資料庫之間很強的一致性,但是使用不當會導致原有資料的丟失,請務必做好資料庫的定時備份。

Entity Framework Core的全自動遷移

Entity Framework Core去掉了SetInitializer選項,取而代之的是DatabaseFacade.MigrateDatabaseFacade.EnsureCreated
DatabaseFacade.Migrate可以應用使用ef命令生成的遷移程式碼,避免在生產環境中執行ef命令。
DatabaseFacade.EnsureCreated則從頭建立所有資料表和欄位,但只能建立不能更新,不會新增紀錄到__MigrationHistory
這兩個函式都不能實現全自動遷移,ZKWeb框架使用了EF內部提供的函式,完整程式碼可以檢視這裡

Entity Framework Core的自動遷移實現比較複雜,我們需要分兩步走。

  • 第一步 建立遷移記錄__ZKWeb_MigrationHistory表,這個表和EF自帶的結構相同,但這個表是給自己用的不是給ef命令用的
  • 第二部 查詢最後一條遷移記錄,和當前的結構進行對比,找出差異並更新資料庫

第一步的程式碼使用了EnsureCreated建立資料庫和遷移記錄表,其中EFCoreDatabaseContextBase只有遷移記錄一個表。
建立完以後還要把帶遷移記錄的結構保留下來,用作後面的對比,如果這裡不保留會導致遷移記錄的重複建立錯誤。

using (var context = new EFCoreDatabaseContextBase(Database, ConnectionString)) {
    // We may need create a new database and migration history table
    // It's done here
    context.Database.EnsureCreated();
    initialModel = context.Model;
}

在執行第二步之前,還需要先判斷連線的資料庫是不是關係資料庫,
因為Entity Framework Core以後還會支援redis mongodb等非關係型資料庫,自動遷移只應該用在關係資料庫中。

using (var context = new EFCoreDatabaseContext(Database, ConnectionString)) {
    var serviceProvider = ((IInfrastructure<IServiceProvider>)context).Instance;
    var databaseCreator = serviceProvider.GetService<IDatabaseCreator>();
    if (databaseCreator is IRelationalDatabaseCreator) {
        // It's a relational database, create and apply the migration
        MigrateRelationalDatabase(context, initialModel);
    } else {
        // It maybe an in-memory database or no-sql database, do nothing
    }
}

第二步需要查詢最後一條遷移記錄,和當前的結構進行對比,找出差異並更新資料庫。

先看遷移記錄表的內容,遷移記錄表中有三個欄位

  • Revision 每次遷移都會+1
  • Model 當前的結構,格式是c#程式碼
  • ProductVersion 遷移時Entity Framework Core的版本號

Model存放的程式碼例子如下,這段程式碼記錄了所有表的所有欄位的定義,是自動生成的。
後面我將會講解如何生成這段程式碼。

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using ZKWeb.ORM.EFCore;

namespace ZKWeb.ORM.EFCore.Migrations
{
    [DbContext(typeof(EFCoreDatabaseContext))]
    partial class Migration_636089159513819123 : ModelSnapshot
    {
        protected override void BuildModel(ModelBuilder modelBuilder)
        {
            modelBuilder
                .HasAnnotation("ProductVersion", "1.0.0-rtm-21431")
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);

            modelBuilder.Entity("Example.Entities.Foo", b =>
                {
                    b.Property<Guid>("Id")
                        .ValueGeneratedOnAdd();

                    b.Property<string>("Name")
                        .IsRequired();
                });
            }
        }
    }
}

接下來查詢最後一條遷移記錄:

var lastModel = initialModel;
var histories = context.Set<EFCoreMigrationHistory>();
var lastMigration = histories.OrderByDescending(h => h.Revision).FirstOrDefault();

存在時,編譯Model中的程式碼並且獲取ModelSnapshot.Model的值,這個值就是上一次遷移時的完整結構。
不存在時,將使用initialModel的結構。
編譯使用的是另外一個元件,你也可以用Roslyn CSharp Scripting包提供的介面編譯。

if (lastMigration != null) {
    // Remove old snapshot code and assembly
    var tempPath = Path.GetTempPath();
    foreach (var file in Directory.EnumerateFiles(
        tempPath, ModelSnapshotFilePrefix + "*").ToList()) {
        try { File.Delete(file); } catch { }
    }
    // Write snapshot code to temp directory and compile it to assembly
    var assemblyName = ModelSnapshotFilePrefix + DateTime.UtcNow.Ticks;
    var codePath = Path.Combine(tempPath, assemblyName + ".cs");
    var assemblyPath = Path.Combine(tempPath, assemblyName + ".dll");
    var compileService = Application.Ioc.Resolve<ICompilerService>();
    var assemblyLoader = Application.Ioc.Resolve<IAssemblyLoader>();
    File.WriteAllText(codePath, lastMigration.Model);
    compileService.Compile(new[] { codePath }, assemblyName, assemblyPath);
    // Load assembly and create the snapshot instance
    var assembly = assemblyLoader.LoadFile(assemblyPath);
    var snapshot = (ModelSnapshot)Activator.CreateInstance(
        assembly.GetTypes().First(t =>
        typeof(ModelSnapshot).GetTypeInfo().IsAssignableFrom(t)));
    lastModel = snapshot.Model;
}

和當前的結構進行對比:

// Compare with the newest model
var modelDiffer = serviceProvider.GetService<IMigrationsModelDiffer>();
var sqlGenerator = serviceProvider.GetService<IMigrationsSqlGenerator>();
var commandExecutor = serviceProvider.GetService<IMigrationCommandExecutor>();
var operations = modelDiffer.GetDifferences(lastModel, context.Model);
if (operations.Count <= 0) {
    // There no difference
    return;
}

如果有差異,生成遷移命令(commands)和當前完整結構的快照(modelSnapshot)。
上面Model中的程式碼由這裡的CSharpMigrationsGenerator生成,modelSnapshot的型別是string

// There some difference, we need perform the migration
var commands = sqlGenerator.Generate(operations, context.Model);
var connection = serviceProvider.GetService<IRelationalConnection>();
// Take a snapshot to the newest model
var codeHelper = new CSharpHelper();
var generator = new CSharpMigrationsGenerator(
    codeHelper,
    new CSharpMigrationOperationGenerator(codeHelper),
    new CSharpSnapshotGenerator(codeHelper));
var modelSnapshot = generator.GenerateSnapshot(
    ModelSnapshotNamespace, context.GetType(),
    ModelSnapshotClassPrefix + DateTime.UtcNow.Ticks, context.Model);

插入遷移記錄並執行遷移命令:

// Insert the history first, if migration failed, delete it
var history = new EFCoreMigrationHistory(modelSnapshot);
histories.Add(history);
context.SaveChanges();
try {
    // Execute migration commands
    commandExecutor.ExecuteNonQuery(commands, connection);
} catch {
    histories.Remove(history);
    context.SaveChanges();
    throw;
}

到這裡就完成了Entity Framework Core的自動遷移,以後每次有更新都會對比最後一次遷移時的結構並執行更新。
Entity Framework Core的遷移特點和Entity Framework一樣,可以保證很強的一致性但需要注意防止資料的丟失。

寫在最後

全自動遷移資料庫如果正確使用,可以增強專案中各個模組的獨立性,減少開發和部署的工作量。
但是因為不能手動控制遷移內容,有一定的侷限和危險,需要了解好使用的ORM遷移的特點。

寫在最後的廣告

ZKWeb網頁框架已經在實際專案中使用了這項技術,目前來看遷移部分還是比較穩定的。
這項技術最初是為了外掛商城而開發的,在下載安裝外掛以後不需要重新編譯主程式,不需要執行任何遷移命令就能使用。
目前雖然沒有實現外掛商城,也減少了很多日常開發的工作。

如果你有興趣,歡迎加入ZKWeb交流群522083886共同探討。