【EF Core】“Code First”方案下以编程方式生成迁移
迁移(Migrations)是个啥玩意?IT 界从来不缺造词人才,总喜欢造各种各样的词。之所以叫迁移,大概是因为使用它可以创建并在后期修订数据库。总之,说人话就是迁移可以生成一系列的 .NET 类,每个类代表一个修订版本。开发者可以在多个版本之间“进”或“退”——可以修改数据库,之后可以撤销前一次修改。注意,这里说的修改 / 修订不是指数据,而是数据库的基础结构,比如,某个表后面由于某些原因,要添加一列,或要删除一列。
大伙伴都知道,调用 dbContext.Database.EnsureCreated 方法可以根据配置的 Model 创建数据库,它与迁移最大的区别就是:EnsureCreated 方法创建的数据库在后期是不能修改的(可以手动执行 SQL 语句来修改)。而迁移在创建数据库时它会顺便把当前迁移的版本信息保存到数据库(实体类 HistoryRow 类,包含两个属性:MigrationId 表示迁移ID,ProductVersion 表示 EF Core 版本),这样可以通过版本对比来确定版本的前进和回退,也可依此判定哪些迁移已应用到数据库,哪些还没同步到数据库。
为了能友好地分辨出迁移版本,在生成迁移代码时,开发者可以自定义一个命名。其格式是“<当前日期>_<自定义名称>”。例如“20251213102915_abc”,即开发者实际指定的命名是“abc”,前缀的时间和下画线是 EF Core 设计时服务自动加的。生成此名称是由 IMigrationsIdGenerator 服务接口负责的,默认的实现类是 MigrationsIdGenerator。咱们不妨看看 GenerateId 方法的源码:
public virtual string GenerateId(string name) { var now = DateTime.UtcNow; var timestamp = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second); …… // 这里拼接新名称 return timestamp.ToString(Format, CultureInfo.InvariantCulture) + "_" + name; }
很简单粗暴吧,就是获取当前时间(精确到秒,足够了,你该不会每秒生成一次这么无聊吧),然后加上“_”字符,再加上你给的名字。有大伙伴会问,我要是不喜欢这种命名方式,我自己写个类实现 IMigrationsIdGenerator 接口,注册到服务容器中替换到框架的默认实现,那是不是可以实现以自己喜欢的方式生成迁移命名呢?答案是肯定的。
迁移是由一系列 Operation 组成,用 MigrationOperation 类表示。依据修改数据库的各种骚操作派生出相应的类。如 AlterTableOperation 表,它代表 SQL 语句:ALTER TABLE ...;再比如 AddColumnOperation 类,它代表 ALTER TABLE <表> ADD <新列> 语句,SqlServerCreateDatabaseOperation 类代表 CREATE DATABASE <数据库名> 语句,等等。这些类都能在 Microsoft.EntityFrameworkCore.Migrations.Operations 命名空间下找到。
在迁移的时候,会根据这些 Operation 生成关联的 SQL 语句。例如,下面源码是生成添加新列的 SQL。
protected virtual void Generate( AddColumnOperation operation, IModel? model, MigrationCommandListBuilder builder, bool terminate = true) { if (operation[RelationalAnnotationNames.ColumnOrder] != null) { Dependencies.MigrationsLogger.ColumnOrderIgnoredWarning(operation); } builder .Append("ALTER TABLE ") .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)) .Append(" ADD "); ColumnDefinition(operation, model, builder); if (terminate) { builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator); EndStatement(builder); } } protected virtual void ColumnDefinition( string? schema, string table, string name, ColumnOperation operation, IModel? model, MigrationCommandListBuilder builder) { if (operation.ComputedColumnSql != null) { ComputedColumnDefinition(schema, table, name, operation, model, builder); return; } var columnType = operation.ColumnType ?? GetColumnType(schema, table, name, operation, model); builder .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name)) .Append(" ") .Append(columnType); if (operation.Collation != null) { builder .Append(" COLLATE ") .Append(operation.Collation); } builder.Append(operation.IsNullable ? " NULL" : " NOT NULL"); DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, builder); }
迁移代码的基类是 Migration,它是抽象类。派生类通常要实现以下成员:
1、向上版本,即新版本被应用到数据库时要修改的内容。
protected abstract void Up(MigrationBuilder migrationBuilder);
2、向下版本,即回退功能,当此版本的迁移被取消时要撤销的内容。
protected virtual void Down(MigrationBuilder migrationBuilder);
什么是向上向下呢?举个例子,假如你一开始的数据库中,A表只有三个列。后来由于客户兽性大发要改需求,随后你给A表添加了一个新列 F。于是生成了迁移。此时,该迁移的向上操作(升级)就是给 A 表 add column;但是,过了一个星期后,客户逐渐恢复人性,又要求你改回去。这时候你要回到上一个迁移,最新一个迁移所做的修改要撤回,这就是向下操作(降级)。这时向下操作要把 F 列删除。说一句话就是:UP 加 F 列,DOWN 删 F 列。
3、构建数据库模型。
protected virtual void BuildTargetModel(ModelBuilder modelBuilder);
这个其实和 DbContext.OnModelCreating 方法中实现数据库模型配置的逻辑相同。区别是迁移这里的 ModelBuilder 实例是没有添加预置约定集合。也就是说它不能自动帮你识别实体的属性,不能自动识别导航属性,不能自动分析主键。你必须一五一十老老实实地完成所有配置。实际上这里的模型配置既麻烦且重复的,所以才用工具生成,而不是你动手去写。毕竟写重复代码意义不大,之所以要在这里重新配置一遍,是出于优化和效率。这里的 ModelBuilder 没有约定类,没有 ModelCustomer 等服务,精简了许多,但信息量又比运行时模型完整些。
为了让代码生成器了解这个从 Migration 派生的类就是一个迁移,还要在这个类上应用 [Migration] 特性(MigrationAttribute 类),并设置 Id 属性,即指定迁移的 ID。前文老周已经介绍了,ID的格式是 <当前时间>_<名称>。就算你闲着没事,想自己手动写一个迁移类,你的 Id 也必须遵守这个格式,否则代码生成器是找不到迁移的,因为它在查找迁移时,同样用到了 IMigrationsIdGenerator 服务来获取迁移名称。所以,你不按这个格式套的话,正则表达式无法匹配,就找不到了。
最后,还要在迁移类上加上 [DbContext] 特性,表明这个迁移是与哪个 dbContext 关联的。
除了迁移类,一般会伴随一个快照类——该类从 ModelSnapshot 派生。这个类里又要配置一次数据库模型。
protected abstract void BuildModel(ModelBuilder modelBuilder);
你说,如果手动写代码是不是很烦,老是重复配置模型。和 Migration 类一样,快照类的 ModelBuilder 也是空的,没有添加预置约定类,所以它不能自动完成某些通用配置的,也是手动档的。这个快照类等于你给 dbContext 的最新模型拍个照,生成迁移时候用于对比新旧版本产生了哪些变化。
同理,快照类上也要用 [DbContext] 特性标记它与哪个 dbContext 类相关联。
和上一篇介绍从数据库生成模型一样,根据实体 Model 生成更新数据库的迁移代码也要依赖一些服务。接下来老周简单介绍一下这些服务,大伙伴们了解一下就好了,因为我们一般不会直接调用它们(不一般的情况极少见)。
1、当前 DbContext 实例如何获取。用 ICurrentDbContext 服务,咱们现在是直接编程了,不是用 dotnet-ef 工具,所以不要反射那么麻烦了,直接实例化你用的 dbContext,然后把它的服务搬到设计时服务集合中就行了。这个你不要紧张,不用咱们写代码,EF Core 已经封装好了,稍后介绍。
2、当前上下文的数据库模型(IModel),这个也是直接从 DbContext 实例搬过来就行,同样,EF Core 已经封装好了,你不用写一行代码。
3、IMigrationsModelDiffer:这是今天四大天皇巨星之一。它用于分析两个数据库模型之间的差异,并创建一个用于更新数据库的 MigrationOperation 列表。
4、IMigrationsIdGenerator:四大天皇巨星之二,它是核心服务,用于生成迁移代码。默认实现类是 MigrationsCodeGenerator,它是抽象类,不同编程语言可以继承并实现生成的代码。目前内置的只有 CSharpMigrationsGenerator 类,所以只能生成 C# 代码。
5、IMigrationsCodeGeneratorSelector:四大天皇巨星之三,用于选择使用哪个 MigrationsCodeGenerator,目前只不过是根据“C#”选择 CSharpMigrationsGenerator,以后可能有其他扩展。
6、IHistoryRepository:四大天皇巨星之四,它用来访问(创、删、读)数据库中的迁移版本历史表。这个和生成代码关系不大,但它和删除迁移代码有关,用于获取已经应用到数据库的迁移版本。
以上服务仅作了解,大伙伴们忘了也无所谓,但下面这个重量级巨星,大伙得记住它。它,就是 IMigrationsScaffolder 服务,默认实现类是 MigrationsScaffolder。它定义了以下方法:
1、生成代码不保存。
ScaffoldedMigration ScaffoldMigration( string migrationName, string? rootNamespace, string? subNamespace = null, string? language = null, bool dryRun = false);
这是核心功能,生成迁移代码。只是生成了代码,未保存到文件。migrationName 参数指定迁移名称,rootNamespace 指定项目的根命名空间,可以为空。subNamespace 参数是子命名空间,即迁移类所在的命名空间。language 参数是编程语言,反正都是“C#”,目前这个参数可以忽略。dryRun 是啥,“干运行”?不用管它,在这个方法中它没有用上。
2、保存生成的代码到文件。
MigrationFiles Save( string projectDir, ScaffoldedMigration migration, string? outputDir, bool dryRun = false);
projectDir参数是项目所在目录。migration 参数传的是上面 ScaffoldMigration 方法返回的对象。outputDir 参数是输出目录,它相对于 projectDir 指定的目录。dryRun,你看,干运行又来了。这次它起作用了,如果为 true,则不会真正保存文件。默认为 false,会保存文件。这个你可以看看源代码。
if (!dryRun) { Directory.CreateDirectory(migrationDirectory); File.WriteAllText(migrationFile, migration.MigrationCode, Encoding.UTF8); File.WriteAllText(migrationMetadataFile, migration.MetadataCode, Encoding.UTF8); Dependencies.OperationReporter.WriteVerbose(DesignStrings.WritingSnapshot(modelSnapshotFile)); Directory.CreateDirectory(modelSnapshotDirectory); File.WriteAllText(modelSnapshotFile, migration.SnapshotCode, Encoding.UTF8); }
总共产生三个文件:迁移类,迁移类元数据(其实和迁移类是同一个类,声明为部分类),以及快照类。
好了,到了这里,相信悟性惊人的大伙伴可能知道怎么用了。咱们来演示一下。
先弄些实体和上下文。
/// <summary> /// 动物 /// </summary> public class Animal { public Guid AnId { get; set; } /// <summary> /// 昵称 /// </summary> public string Nick { get; set; } = default!; /// <summary> /// 年龄 /// </summary> public int Age { get; set; } /// <summary> /// 详细信息 /// </summary> public AnimalDetail? Details { get; set; } } /// <summary> /// 动物详细信息 /// </summary> public class AnimalDetail { public int AniID { get; set; } /// <summary> /// 门 /// </summary> public string? Phylum { get; set; } /// <summary> /// 纲 /// </summary> public string? Class { get; set; } /// <summary> /// 目 /// </summary> public string? Order { get; set; } = default!; /// <summary> /// 科 /// </summary> public string Family { get; set; } = default!; /// <summary> /// 属 /// </summary> public string Genus { get; set; } = default!; } public class DemoDbContext : DbContext { // 数据集合 public DbSet<Animal> Animals { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer(@"server=(localdb)\mssqllocaldb;database=AnimalDB"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 详细类 var entAnmDetail = modelBuilder.Entity<AnimalDetail>(); // 属性 entAnmDetail.Property(b => b.AniID).HasColumnName("detail_id"); entAnmDetail.Property(b => b.Phylum).HasColumnName("phylum") .HasMaxLength(20); entAnmDetail.Property(f => f.Class) .HasColumnName("class") .HasMaxLength(32); entAnmDetail.Property(d => d.Order) .HasColumnName("order") .HasMaxLength(32); entAnmDetail.Property(g => g.Family).HasColumnName("family") .HasMaxLength(32); entAnmDetail.Property(m => m.Genus).HasColumnName("genus") .HasMaxLength(32); // 影子属性,作为外键 entAnmDetail.Property<Guid>("Animal_id").HasColumnName("animal_id"); // 主键 entAnmDetail.HasKey(k => k.AniID).HasName("PK_Animal_details"); // 动物类 var entAnimal = modelBuilder.Entity<Animal>(); entAnimal.Property(a => a.AnId).HasColumnName("anl_id"); entAnimal.Property(a => a.Nick).HasColumnName("nick").HasMaxLength(15); entAnimal.Property(d => d.Age).HasColumnName("age"); // 主键 entAnimal.HasKey(x => x.AnId).HasName("PK_Animal"); // 一对一,详细表引用动物表 entAnimal.HasOne(p => p.Details) .WithOne() // 定义外键 .HasForeignKey<AnimalDetail>("Animal_id") .HasConstraintName("FK_Animal") // AnimalDetail -> Animal .HasPrincipalKey<Animal>(n => n.AnId); } }
迁移功能是给开发者用的,程序正常启动不应该去生成迁移代码,所以,和上一篇水文一样,咱们用条件编译。
#define GEN_MIGRATION …… static void Main(string[] args) { #if GEN_MIGRATION MakeMigration("test666"); return; #endif // 应用程序主代码…… }
下面咱们完成 MakeMigration 方法,实现迁移类和快照类的生成。
#if GEN_MIGRATION static void MakeMigration(string migName) { // 项目目录 const string ProjectDir = @"..\..\..\"; // 输出目录 //const string OutputDir = "MyMigrations"; // 迁移命名空间 const string migNs = "DBUpdates"; // 项目根命名空间 const string rootNs = nameof(MigrationGenApp); // 正常操作 using var context = new DemoDbContext(); // 服务容器 var servicesCollection = new ServiceCollection(); // 添加设计时基础服务 servicesCollection.AddEntityFrameworkDesignTimeServices(); // 把dbcontext的服务也添加进去 servicesCollection.AddDbContextDesignTimeServices(context); // 添加数据库相关的设计时服务 string providerAssemblyName = context.Database.ProviderName!; // 找到设计时服务类 Assembly providerAss = Assembly.Load(providerAssemblyName); var designtimeSvcAttr = providerAss.GetCustomAttribute<DesignTimeProviderServicesAttribute>(); Type svcType = providerAss.GetType(designtimeSvcAttr!.TypeName)!; // 动态实例化 IDesignTimeServices designtimeSvc = (IDesignTimeServices)Activator.CreateInstance(svcType)!; // 配置服务 designtimeSvc.ConfigureDesignTimeServices(servicesCollection); // 构建服务 var services = servicesCollection.BuildServiceProvider(); // 获取迁移服务 IMigrationsScaffolder migscaff = services.GetRequiredService<IMigrationsScaffolder>(); // 直接开干 var migres = migscaff.ScaffoldMigration( migrationName: migName, // 名称 rootNamespace: rootNs, // 根命名空间 subNamespace: migNs // 迁移类的命名空间 ); // 保存 var saveres = migscaff.Save(ProjectDir, migres, null/*, OutputDir*/); Console.WriteLine($"快照文件:{saveres.SnapshotFile}"); Console.WriteLine($"元数据文件:{saveres.MetadataFile}"); Console.WriteLine($"迁移类文件:{saveres.MigrationFile}"); } #endif
上一篇水文中,咱们是没有 DbContext 的,而是根据现有数据库来生成的。但这次不同,咱们有 DbContext 的,所以,操作上有一点点不同。
1、正常方式 new 一个数据库上下文,本示例中是 DemoDbContext 类。
2、实例化服务容器集合,这个和上次一样。
3、调用 AddEntityFrameworkDesignTimeServices 扩展方法,添加设计时相关的基础服务,这个和上次一样。
4、注意,这次咱们多了这一步,调用 AddDbContextDesignTimeServices 扩展方法,把 DbContext 实例的服务也添加到服务容器中。这是因为咱们生成迁移代码需要用到数据库上下文以及它里面的某些服务。
5、这一步和上次一样,通过数据库提供者库中应用到程序集的 [DesignTimeProviderServices] 特性,得到设计时服务类的类型。创建实例,调用 ConfigureDesignTimeServices 方法完成配置。
6、BuildServiceProvider 方法调用后,产生完整的服务容器。
7、获取 IMigrationsScaffolder 服务。
8、调用 ScaffoldMigration 方法生成代码,未保存。
9、调用 Save 方法真正保存。注意,这里 outputDir 参数有个超级大坑。注释里面说,它是相对于项目目录的,而实际上并不是。从源代码中找到这个坑。
public virtual MigrationFiles Save(string projectDir, ScaffoldedMigration migration, string? outputDir, bool dryRun) { var lastMigrationFileName = migration.PreviousMigrationId + migration.FileExtension; // 这里是保存迁移类的路径,注意“??”运算符 var migrationDirectory = outputDir ?? GetDirectory(projectDir, lastMigrationFileName, migration.MigrationSubNamespace); var migrationFile = Path.Combine(migrationDirectory, migration.MigrationId + migration.FileExtension); var migrationMetadataFile = Path.Combine(migrationDirectory, migration.MigrationId + ".Designer" + migration.FileExtension); var modelSnapshotFileName = migration.SnapshotName + migration.FileExtension; var modelSnapshotDirectory = GetDirectory(projectDir, modelSnapshotFileName, migration.SnapshotSubnamespace); var modelSnapshotFile = Path.Combine(modelSnapshotDirectory, modelSnapshotFileName); Dependencies.OperationReporter.WriteVerbose(DesignStrings.WritingMigration(migrationFile)); if (!dryRun) { Directory.CreateDirectory(migrationDirectory); File.WriteAllText(migrationFile, migration.MigrationCode, Encoding.UTF8); File.WriteAllText(migrationMetadataFile, migration.MetadataCode, Encoding.UTF8); Dependencies.OperationReporter.WriteVerbose(DesignStrings.WritingSnapshot(modelSnapshotFile)); Directory.CreateDirectory(modelSnapshotDirectory); File.WriteAllText(modelSnapshotFile, migration.SnapshotCode, Encoding.UTF8); } return new MigrationFiles { MigrationFile = migrationFile, MetadataFile = migrationMetadataFile, SnapshotFile = modelSnapshotFile, Migration = migration }; }
仔细看这里
var migrationDirectory = outputDir ?? ...
也就是说,如果你的 outputDir 参数不是 null 的话,它直接用来作为迁移类输出目录,这就不是相对于项目目录了,而是相对于程序运行的当前目录了。只有当 outputDir 参数给了 null 后,它才调用 GetDirectory 在项目目录同级目录下以分隔子命名空间的结构来拼接目录。比如,我这里的项目根命名空间为 MigrationGenApp, 我指定的迁移类子命名空间为 DBUpdates,于是,若 outputDir 参数为 null 时,它生成相对于当前项目目录的目录 DBUpdates。如下图所示。

所以,要让迁移类和快照类放一起,outputDir 参数还得刻意配置一下。
static void MakeMigration(string migName) { // 项目目录 const string ProjectDir = @"..\..\..\"; // 输出目录 const string OutputDir = @"..\..\..\MyMigrations"; …… // 保存 var saveres = migscaff.Save(ProjectDir, migres, OutputDir); …… }
这样才算把迁移类的代码放到项目目录下了。

接下来,咱们回到 Animal 实体,加一个 Remark 属性。
public class Animal { ……/// <summary> /// 备注 /// </summary> public string? Remark { get; set; } }
再到 DemoDbContext 类的 OnModelCreating 方法,配置改一下。
entAnimal.Property(n => n.Remark).HasColumnName("remarks") .HasMaxLength(250);
最后在 Main 方法中,把刚才 test666 的迁移名称改为 test777。
static void Main(string[] args) { #if GEN_MIGRATION MakeMigration("test777"); return; #endif // 应用程序主代码…… }
第一次生成的迁移代码不要删除,否则不能比较了。
再运行一下,看看新生成的迁移类。
我们来对比一下,test666 迁移类的 Up 和 Down 方法是空的,因为这是第一次,等于是全新创建数据库,所以,要 CREATE TABLE。
protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: "Animals", columns: table => new { anl_id = table.Column<Guid>(type: "uniqueidentifier", nullable: false), nick = table.Column<string>(type: "nvarchar(15)", maxLength: 15, nullable: false), age = table.Column<int>(type: "int", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Animal", x => x.anl_id); }); migrationBuilder.CreateTable( name: "AnimalDetail", columns: table => new { detail_id = table.Column<int>(type: "int", nullable: false) .Annotation("SqlServer:Identity", "1, 1"), phylum = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true), @class = table.Column<string>(name: "class", type: "nvarchar(32)", maxLength: 32, nullable: true), order = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true), family = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false), genus = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false), animal_id = table.Column<Guid>(type: "uniqueidentifier", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Animal_details", x => x.detail_id); table.ForeignKey( name: "FK_Animal", column: x => x.animal_id, principalTable: "Animals", principalColumn: "anl_id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( name: "IX_AnimalDetail_animal_id", table: "AnimalDetail", column: "animal_id", unique: true); } /// <inheritdoc /> protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( name: "AnimalDetail"); migrationBuilder.DropTable( name: "Animals"); }
而 test777 只是添加了一列,所以是 ADD COLUMN。
protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "remarks", table: "Animals", type: "nvarchar(250)", maxLength: 250, nullable: true); } /// <inheritdoc /> protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( name: "remarks", table: "Animals"); }
现在,迁移代码已生成,咱们不需要再运行它们了,把条件编译注释掉。
//#define GEN_MIGRATION
调用 Migrate 方法,有两个重载。如果传递迁移名称,那么只会把那个迁移应用么数据库;如果无参数调用,就会把所有未应用的迁移全部同步到数据库。由于咱们刚刚创建了两个迁移,而且没有数据库,所以咱们是从零构建,要调用无参数的 Migrate 方法。
static void Main(string[] args) { #if GEN_MIGRATION MakeMigration("test777"); return; #endif // 应用程序主代码…… using var context = new DemoDbContext(); // 全部应用迁移 context.Database.Migrate(); }
运行程序后,注意数据库里会多了一个 __EFMigrationsHistory 表,它就是用来记录迁移版本的。

它里面其实只存了迁移ID和 EF 版本。

好了,今天就水到这里了。

浙公网安备 33010602011771号