在開發涉及到數據庫的程序時,常會遇到一開始設計的結構不能滿足需求需要再添加新字段或新表的情況,這時就需要進行數據庫遷移。
實現數據庫遷移有很多種辦法,從手動管理各個版本的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.Migrate
和DatabaseFacade.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共同探討。