我最早接触 EF Core 时,对“Code First”方案的理解一直停留在三步走:写实体、写 DbContext、打开终端执行 dotnet ef migrations add xxx,再 dotnet ef database update。这套流程自己开发时用得很顺手,直到后来我开始负责一款需要交付给客户独立部署的企业应用——客户服务器上只有 .NET 运行时,没有 SDK,更不允许随便装开发工具链,而且强烈要求系统里能提供一个“数据库自动升级”的入口,让不熟悉命令行的运维也能操作。这时候我才意识到,迁移的生成动作如果只能依赖命令行,很多真实的交付场景根本跑不起来。
EF Core 的迁移能力本身是可以脱离 CLI 独立存在的。我花了不少时间翻了设计时服务那套代码,最后在应用内部以编程方式把“生成迁移”这件事给做通了。这篇文章就围绕这个话题展开:为什么需要绕过 dotnet ef 直接调迁移生成器、底层是怎么工作的、具体代码怎么落地,以及我在这条路上踩过哪些坑。适合正在做模块化系统、自动化升级平台,或者单纯想把迁移机制弄得更透彻的 .NET 开发者。
1. 从命令行到运行时,为什么会有“想要在代码里生成迁移”这件事
1.1 命令行能做的事,其实就那几个
先说一个经常被忽略的事实:dotnet ef 命令行工具并不是 EF Core 运行时的一部分,它只是一个独立的“设计时入口”。它的工作流程大致是:启动项目、找到 DbContext、加载模型、调用内部迁移生成器、把生成的 C# 文件写到磁盘上。整个过程本质上是在“程序外”操作的。
这也是为什么执行迁移命令时经常要求“项目能成功编译”,因为工具要从编译产物里反射出你的上下文和实体模型。如果项目不是标准的可启动类型,或者某个服务依赖了配置文件、环境变量,命令行工具往往会因为无法完成实例化而抛出一堆让人头疼的异常。
常规开发里,靠 CLI 生成迁移没有任何问题。但在某些产品化场景下,生成迁移这个动作的“执行主体”需要有变化:
- 交付给客户的系统,需要自动检测数据库结构差异并升级,不能要求现场人员敲命令。
- 模块化平台里,业务模块是运行时动态加载的,每次发布都可能带来新的实体变更。
- 程序提供“在线升级数据库”功能,点一下按钮就把迁移文件生成并应用完。
在这些场景里,就需要让程序自己具备“生成迁移”的能力。
1.2 当“生成”必须发生在程序内部
我最开始尝试过的方案是曲线救国:在后台任务里用 Process.Start 去启动一个新的 dotnet ef 进程。思路很直接,代码也简单,但实际用起来问题很多。
首先是目标机器的环境问题。客户服务器可能根本就没装 .NET SDK,而 dotnet ef 必须依赖 SDK 来编译和加载项目。然后是指定项目路径的问题,发布后的目录结构跟开发时完全不同,需要手工去拼各种 .dll 和 .deps.json 的路径,非常脆弱。最后还有一个恶心的点,进程启动是异步的,如果前一次生成尚未结束,下一次请求进来就会打架,出现文件写入冲突。
与其在外面调度一个黑盒进程,不如直接进入到 EF Core 内部把生成器调起来。这个方向才是解决“运行时迁移”的正解。
1.3 别把目标搞混:生成、编译、落地三件事
把“编程方式生成迁移”作为目标之前,得先拆开三个经常被混为一谈的环节:
- 生成迁移代码文件。
- 把生成的代码编译进当前运行的程序集。
- 执行迁移把数据库升级到最新。
我们在命令行里一条命令完成三件事,因为 CLI 本身就在源码环境里工作。可到了运行时,这三件事是分离的。比如,你把 C# 文件写到磁盘后,并不代表当前进程就能立刻使用这段新代码,还需要把迁移类型动态加载进程序集,或者干脆等应用重启。很多人在搜代码生成方案时感到困惑,就是没把这三层职责分清楚。
这篇文章重点讲的是第一件事:如何在进程内调用 EF Core 的迁移生成器,让它像命令行工具一样吐出迁移文件。后面两件事我会在第 3、5 节里结合实际方案展开讨论。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. EF Core 迁移的构造逻辑:它究竟是怎么知道“这次要改什么”的
2.1 迁移目录里的三种文件各司其职
如果有过手写迁移的经历,你会发现用 dotnet ef migrations add 生成的文件其实有三类,每一种都有不能替代的作用。以添加一张 Product 表为例,Migrations 目录里会多出:
{时间戳}_AddProduct.cs,迁移主文件。里面是继承自Migration的类,包含Up和Down方法,定义了“怎么改”和“怎么撤销改”。{时间戳}_AddProduct.Designer.cs,迁移元数据文件。它通过[DbContext(typeof(YourDbContext))]特性告诉 EF Core 这个迁移归属于哪个上下文,内部还包含一个BuildTargetModel方法,用来描述“迁移完成后数据库模型应该长什么样”。YourDbContextModelSnapshot.cs,模型快照文件。它维护的是上一次迁移完成后整个数据库模型的完整状态。每次新增迁移后,这个文件会更新成最新模型。
很多初学者只把注意力放在第一类文件上,遇到迁移冲突时才发现快照和 Designer 文件同样关键。其实这三类文件配合起来,才构成了 EF Core 做差异分析的依据。
2.2 核心比较器:当前模型和上个快照的差异
EF Core 的迁移生成器不是靠数据库反推结构的,它的判定机制是“拿当前上下文模型与上一次迁移留下的快照模型做差异比较”。
我打个比方。模型快照相当于工地上的“已完工图纸”,记录的是上一次施工结束后的房屋形态。你现在手里还有一套“新装修方案”,也就是 DbContext 的 OnModelCreating 里定义的实体集合。当生成迁移时,EF Core 会调用一个内部组件 MigrationsModelDiffer,把这两套图纸逐项比较,发现哪里多了柱子、哪些墙体拆了,然后把差异转化成一个个操作,比如 AddColumn、CreateTable、DropForeignKey。
这里有个很容易踩的认知误区:如果生成的迁移文件没有成功更新快照文件,下一次再执行迁移生成时,生成器会发现上次的差异仍然没有记录在案,于是把同样的迁移再生成一遍。换句话说,快照文件是“上一次操作的真实成果”,而不是锦上添花的附赠品。它必须跟迁移主文件同步更新,否则整个差异链就断了。
2.3 设计时服务那套东西到底是什么
EF Core 里有个概念叫“设计时服务”(Design Time Services),跟运行时服务是两套独立存在的组件。运行时服务负责查询、保存、事务管理;设计时服务负责建模型、算差异、生成迁移文件、生成 SQL 脚本。
正常情况下,dotnet ef 工具会自动把设计时服务装配到一个独立的依赖注入容器里。但如果你想在应用内调用这些服务,应用程序默认的 IServiceCollection 里并没有注册它们,这就是我们下一步要解决的问题。
设计时服务里,我们最关心的几个类型包括:
MigrationsScaffolder:迁移生成的总入口。MigrationsModelDiffer:负责当前模型与快照模型的差异计算。MigrationsCodeGenerator:把差异操作翻译成 C# 代码。MigrationsAssembly:负责定位迁移类所在的程序集。
只要能把它们装进容器并正确解析,就等于把命令行工具的核心“发动机”搬到了自己的程序里。
3. 用代码调用迁移生成器:完整实操路径
3.1 环境准备与依赖版本
我下面的示例是基于 EF Core 7/8 + SQL Server 验证过的,其他数据库原理一样,只需要替换对应的 Provider 包。
项目里至少需要引用这些包:
xml复制<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" />
需要特别说明的是 Microsoft.EntityFrameworkCore.Design 这个包,很多人以为它只在设计时用,以为加上 <PrivateAssets>all</PrivateAssets> 后运行时就完全拿不到 API。其实这个设置只是防止包传递到下游项目,并不会阻止当前项目在运行时引用其中的类型。如果你想在应用里调用 MigrationsScaffolder,这个包必须真实存在于当前项目的依赖里。
3.2 手工组装设计时服务容器
要让生成器跑起来,关键的一步是把设计时服务缺的部分注册进一个独立的依赖注入容器。不能直接拿整个网站的根容器去解析 MigrationsScaffolder,因为根容器往往没有注册 IDesignTimeServices 那套内部组件。
我采用的模式是为每一次迁移生成操作创建独立的 ServiceCollection,用完就释放。核心步骤是:
- 注册所需的 EF Core Provider。
- 注册需要生成迁移的 DbContext。
- 调用扩展方法补充内部设计时服务。
下面这个例子展示了一个控制台/后台任务里生成迁移的完整代码:
csharp复制public class MigrationBootstrap
{
private readonly string _connectionString;
public MigrationBootstrap(string connectionString)
{
_connectionString = connectionString;
}
/// <summary>
/// 以编程方式生成一次迁移
/// </summary>
/// <param name="migrationName">迁移名称,建议包含业务含义</param>
/// <param name="outputDirectory">输出目录,通常是项目里的 Migrations 目录</param>
public void GenerateMigration(string migrationName, string outputDirectory)
{
var services = new ServiceCollection();
// 1. 注册数据库 Provider 与上下文
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(_connectionString, sql =>
{
// 指定迁移程序集为当前入口程序集
sql.MigrationsAssembly(typeof(MigrationBootstrap).Assembly.GetName().Name);
});
});
// 2. 注册 EF Core 设计时内部服务
services.AddEntityFrameworkDesignTimeServices();
// 3. 为指定上下文补充设计时服务
// 如果项目里有多个 DbContext,这一步能避免上下文之间的设计时服务互相污染
services.AddDbContextDesignTimeServices<AppDbContext>();
using var serviceProvider = services.BuildServiceProvider();
// 4. 从容器中解析迁移生成器
var scaffolder = serviceProvider.GetRequiredService<MigrationsScaffolder>();
// 5. 生成迁移,指定迁移文件代码的根命名空间
var migration = scaffolder.ScaffoldMigration(
migrationName,
"MyProduct.Migrations");
// 6. 将三个文件落盘
WriteMigrationFile(outputDirectory, migration.MigrationFile);
WriteMigrationFile(outputDirectory, migration.MetadataFile);
WriteMigrationFile(outputDirectory, migration.SnapshotFile);
}
private void WriteMigrationFile(string outputDirectory, MigrationFile migrationFile)
{
if (string.IsNullOrEmpty(migrationFile?.Content))
{
return;
}
if (!Directory.Exists(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
var path = Path.Combine(outputDirectory, migrationFile.Path);
File.WriteAllText(path, migrationFile.Content, new UTF8Encoding(false));
}
}
ScaffoldMigration 的第一个参数是迁移名称,第二个参数是迁移文件代码的根命名空间。如果迁移代码里包含跨命名空间的类型引用,这里设置错误会导致生成后的文件编译不过。
MigrationFile 这个类型因为版本差异,在不同 EF Core 版本里的属性名称略有不同。我这边使用 8.0 时,它包含 Path 和 Content 两个属性;如果你在用 3.x 或 5.x,可能看到的是 SubNamespace、File 之类,升级到新版本后基本上都以 Path 和 Content 为准。
3.3 控制迁移文件命名、命名空间和落盘路径
上面的代码虽然能生成文件,但实际应用里还有几个“细节决定成败”的参数。
首先是文件的时间戳前缀。ScaffoldMigration 并不会自动补时间戳,传入 migrationName 为 AddProduct,生成的主文件名就会是 AddProduct.cs。但 EF Core 运行时依赖“迁移类名 + 迁移版本”来判定哪些迁移已经应用过,如果文件名里没有时间信息,在多环境协作时会很容易出现版本排序混乱。
EF Core 在运行时读取迁移时会用“迁移 id”来做排序,格式是 {时间戳}_{名称},比如 20250412083000_AddProduct。命令行工具自动生成这种带时间戳的名称,是因为它内部额外做了一次 GetMigrationId 处理。我们在编程调用时也要模拟这个行为。
我常用的简单方案是在传入名称前先拼上时间戳:
csharp复制var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
migrationName = $"{timestamp}_{migrationName}";
不过要注意,如果你调 ScaffoldMigration 时传入的名称已经包含时间戳,那 MigrationsScaffolder 不会再帮你额外加一次,所以拼不拼要看你的约定。我的做法是在封装方法内部统一拼上,避免调用方每次都想这件事。
然后是命名空间和输出路径的配合。ScaffoldMigration 的第二个参数是命名空间,它决定了 Designer.cs 里的 namespace 声明。运行时 EF Core 会扫描迁移程序集中所有继承 Migration 的类,命名空间不同不会影响识别,但它会影响跨程序集访问。比如 DbContext 在 MyProduct.Data,迁移文件在 MyProduct.Data.Migrations,中间就会涉及 MigrationsAssembly 配置。
落盘路径上,我建议输出目录不要写死 Migrations,因为程序运行时的工作目录经常是 bin/Debug/net8.0,写相对路径会跑到意想不到的位置。更稳妥的做法是由调用方传入绝对路径,或者在部署目录里约定一个固定的“升级脚本暂存区”,例如 AppContext.BaseDirectory + "/pending-migrations"。
3.4 生成成功后如何应用数据库
生成文件只是第一步,如果你在开发环境里已经能生成文件,接着就要验证这批迁移是不是能被数据库正确应用。
最简单的做法是在同一个进程里调用 EF Core 的迁移应用能力:
csharp复制using var dbContext = new AppDbContext(CreateOptions(_connectionString));
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
if (pendingMigrations.Count > 0)
{
dbContext.Database.Migrate();
}
Migrate 方法会读取迁移程序集里的所有迁移类,并通过数据库里的 __EFMigrationsHistory 表判断哪些还没执行,然后按顺序执行。
这里有个很重要的前置条件:迁移类型必须能通过反射被加载到当前进程中。如果刚才生成的代码只是被写进了磁盘,没有参与编译,那 GetPendingMigrations() 是看不到这个新迁移的。在项目工程内正常调用时,由于迁移文件就在当前项目里,编译后自然就在程序集里,这条链路是成立的。如果生成目录跟编译输出没有关联,就需要额外考虑动态加载,我会在第 5 节再展开。
4. 排错实录:从注册服务到文件落地的几道坎
4.1 “服务未注册”可能是没引对包
我第一次在独立控制台项目里运行上面代码时,第一行就报了一个很经典的错:
text复制Unable to resolve service for type
'Microsoft.EntityFrameworkCore.Migrations.Design.MigrationsScaffolder'
while attempting to activate ...
当时我的第一个念头是“是不是没注册”,于是回头查 ServiceCollection 里已有的服务。但问题并不完全出在 AddEntityFrameworkDesignTimeServices,而在于我为了减少引用,把 Microsoft.EntityFrameworkCore.Design 包剔除掉了。
前面提过,MigrationsScaffolder 位于设计时包的程序集里。如果你没有引用它,哪怕调用了扩展方法,它也只会注册一部分不包含目标任务的服务。检查这个异常时,第一件事不是翻源码,而是先确认项目文件里 Microsoft.EntityFrameworkCore.Design 是不是真实存在,并且没有被 <PrivateAssets>all</PrivateAssets> 组合限制到无法在运行时解析。
我后来在正式项目里的做法是把这个包放到一个专门的基础设施项目中,而不是依赖 Web 启动项目传递引用,这样语义更清晰,排查也容易。
4.2 生成的迁移文件跑到了 bin 目录
跑通服务注册后,我看到的第二个问题是没有报错,但输出目录怎么都找不到新迁移文件。在控制台程序里执行时,当前工作目录默认是 cwd,可能是调试目录,可能是系统 Temp,反正不是我心里想的“项目下的 Migrations 文件夹”。
排查这个问题的思路是这样的:我一开始在调用代码里写的是相对路径:
csharp复制var outputDirectory = "../../../Migrations";
这个相对路径基于工作目录计算,而工作目录在 IDE 调试和命令行执行时经常不一致。后来我直接把 outputDirectory 作为参数暴露给调用方,而且规定必须是完整路径:
csharp复制var path = Path.Combine(@"D:\repos\MyProduct\src\MyProduct.Data", "Migrations");
为什么不建议自动去找项目目录?因为在生产环境里,应用部署后的目录结构和源码路径毫无关系,试图通过向上遍历目录来找源码文件夹是一种反模式,最后只会把代码搞得很脆。需要明确的一点是:调用编程生成迁移时,操作者自己必须清楚要把文件写到哪,调用 API 的人应该显式传入目标目录。
4.3 同一上下文重复生成同名的代价
有一次我为了测试重复调用场景,连着执行了两次“生成 AddProduct 迁移”。第二次执行时抛出了一个异常,提示迁移类名已经存在,或者看起来没有新增任何操作。
这个问题直接关联到快照机制。第一次生成时,代码里确实创建了迁移文件,但我的生成流程如果把快照文件也更新了,第二次执行时上下文模型跟快照之间的差异已经不存在了,这时候生成器就不会产出新的升级操作,而是返回一个内容基本为空的 EmptyMigration。这会让人误以为生成失败了。
命令行工具为了避免这种问题,会自动维护时间戳前缀,并且每次从项目程序集里读取已经存在的迁移类型。只要你不生成相同 Id 的迁移,它每次都能得到“上一次快照”,然后正确 diff。
所以编程调用时,迁移名称不能只是“业务名”,我再强调一次:必须带上完整的时间戳前缀,比如 20250412090000_AddProduct,既保证唯一性,又保证排序正确。如果你要服务多个上下文,甚至要在迁移名称里加上上下文名,例如 20250412090000_AppDbContext_AddProduct,方便追溯。
4.4 多上下文项目里快照互相覆盖
如果你的解决方案里有多个 DbContext,编程方式调用时比命令行更容易犯一个隐蔽的错误:给 A 上下文生成迁移时,文件正常落在 A 的目录里;接着给 B 上下文生成时,B 的迁移文件里却包含了对 A 模型的操作,或者干脆把快照文件覆盖成 B 的模型。
后者本质上是容器注册的“上下文选择”问题。EF Core 设计时服务在解析上下文时,如果同一个 IServiceCollection 里注册了多个 DbContext,它可能无法确定你要处理哪个。所以我建议每个上下文的生成操作都使用独立的 ServiceCollection,并且在调用 ScaffoldMigration 前显式调用 AddDbContextDesignTimeServices<TContext> 把当前上下文锁死。
在大型项目里,还可以进一步封装一个“迁移工厂”,用字典缓存每个上下文的 Type,避免每次调用时都重新组装容器。下面是一个只保留核心逻辑的示例结构:
csharp复制private static IServiceProvider BuildDesignTimeProvider(Type dbContextType, string connectionString)
{
var services = new ServiceCollection();
var optionsMethod = typeof(DbContextOptionsBuilder)
.GetMethod(nameof(DbContextOptionsBuilder.UseSqlServer),
new[] { typeof(DbContextOptionsBuilder), typeof(string), typeof(Action<SqlServerDbContextOptionsBuilder>) });
services.AddDbContext(dbContextType, options =>
{
optionsMethod?.Invoke(options, new object?[] { options, connectionString, null });
});
services.AddEntityFrameworkDesignTimeServices();
var addMethod = typeof(ServiceCollectionExtensions)
.GetMethods()
.First(m => m.Name == "AddDbContextDesignTimeServices")
.MakeGenericMethod(dbContextType);
addMethod.Invoke(null, new object[] { services });
return services.BuildServiceProvider();
}
这个方案在自动化工具里很实用,因为它可以批量处理模块上下文。
5. 把“编程生成迁移”升级成无人值守运维能力
5.1 一个可行的自动升级工作流
如果你只是想在后台服务里偶尔生成一次迁移,程序内部直接调用就行。可如果目标是让运维点击“自动升级”,你不应该把生成代码和文件落盘打成一个没头没尾的过程,而是要有明确的状态流转。
根据我在项目里的实践,一个相对稳妥的自动升级工作流可以这样设计:
- 触发升级任务。
- 在任务里先检查当前程序集内是否有未应用的新迁移。
- 如果存在未应用迁移,直接调用
Migrate。 - 如果不存在,但发现上下文模型相比快照已经发生了变更,就调用“编程生成”创建新迁移。
- 将生成的迁移文件编译或放置到可被运行程序集识别的目录。
- 再次检查并应用新迁移。
- 记录升级产物和结果日志,失败时提供回滚说明。
这里第 5 步需要根据自己的项目架构来决定。如果迁移文件始终在启动项目源码里,那发布时它已经被编译进 DLL,根本不需要运行时写磁盘。如果一定要在运行期临时生成新迁移代码,那它天然需要一种动态编译或二次启动的机制来加载。
我验证过一条可行的路径是把生成出的长字符串交给 Roslyn 动态编译成一个新的 Migration 实例,然后注入到迁移执行器里。这个方向技术上是能做到的,但复杂度会明显提高,你需要同时管理加载上下文、依赖引用、程序集卸载和编译错误反馈。我个人的建议是,在还没有把“运行期动态新增实体”玩明白之前,别轻易把这条链路做成生产标配,而只把它放到管理后台的“诊断/工具”入口里,由专业实施人员操作。
5.2 必须处理的并发问题和异常恢复
自动升级一旦进入无人值守环境,首先要考虑的不是效率,而是并发。
如果应用是多实例部署,后台任务很可能同时在多台机器上执行“检查版本、生成迁移、应用迁移”,几个进程同时去改 __EFMigrationsHistory,数据库层面很容易出现资源竞争或死锁。EF Core 本身没有内置分布式锁,命令行工具也一样没有,只是通常没人让多个 CLI 进程同时执行。
我在实际项目里采用的方案是引入一个应用级的互斥锁或者数据库级锁。SQL Server 可以用 sp_getapplock,PostgreSQL 可以用 pg_advisory_lock,它们都能在数据库全局层面让同一时间只有一个升级任务执行。下面是一个 SQL Server 环境下的简单封装思路:
csharp复制public static async Task<IDisposable> AcquireMigrationLockAsync(string connectionString, string lockName)
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
var command = connection.CreateCommand();
command.CommandText = @"
DECLARE @result int;
EXEC @result = sp_getapplock
@Resource = @lockName,
@LockMode = 'Exclusive',
@LockOwner = 'Session',
@LockTimeout = 10000;
SELECT @result;";
command.Parameters.AddWithValue("@lockName", lockName);
var result = (int)(await command.ExecuteScalarAsync())!;
if (result < 0)
{
throw new InvalidOperationException("获取迁移锁失败,可能已有另一个升级任务在执行。");
}
return new SqlAppLockRelease(connection, command);
}
同时,异常恢复也要提前设计。生成迁移和数据库应用是两个风险等级不同的阶段:生成阶段即使失败,无非是磁盘上多了一些半成品文件,可以清理重试;一旦 Migrate 开始执行,数据库可能已经处于部分升级状态。所以生产环境里,应用迁移前最好先导出当前结构的备份或生成回滚脚本,PostgreSQL 可以用保存点,SQL Server 在迁移比较复杂时也可以考虑事务性迁移。
我在升级服务里设置了“只允许一个任务运行”的信号量,并且把应用迁移的 DbContext 的 Database.AutoTransactionsEnabled 保留默认开启,让单次 Migrate 尽可能在一个数据库事务里执行。虽然 EF Core 的迁移并不能保证所有操作都能可靠回滚,但至少 SQL Server 常见的 DDL 操作多数能在事务里恢复。
5.3 更复杂的动态迁移:是否要走到 Roslyn 这步
我前面反复提到“迁移文件必须被当前程序集识别”,这是最容易让编程生成方案卡住的深层障碍。因为项目正常发布后,程序集实际上已被编译,二进制里有多少迁移类型是固定的。如果你只是在运行时多写了一份 .cs 文件,EF Core 的 MigrationsAssembly 只会扫描已被加载的程序集,不会去磁盘上找源代码。
想要真正实现“进程不重启、新代码立即生效”,只能把生成的迁移代码变成一个可被加载的类型。最通用的做法是使用 Roslyn 的 CSharpCompilation,把 ScaffoldMigration 返回的 C# 文本动态编译成一个内存程序集,再通过 AssemblyLoadContext 加载。这样 EF Core 就能把新迁移类型识别出来。
这块逻辑的复杂度不低,至少要处理:
- 引用程序集路径。编译动态代码时,要把当前进程运行时的
Assembly.Location全部加入引用。 AppDbContext所在程序集是否能被编译环境感知。- 动态程序集的上下文如何保证不与其他模块冲突。
- 生成代码里如果引用了你自己的实体类型,这些类型所在的程序集也需要被正确加入编译。
很多模块化框架(比如一些插件平台)之所以能实现“页面里点按钮就建表”,底层基本就是 Roslyn + 迁移生成这套组合拳。如果你不需要这种热加载能力,建议退而求其次:把迁移文件写到项目源码目录,然后通过 CI/CD 的编译和发布流程让新代码进入下一个版本。这也是最可控且最容易排查的方案。
在我目前负责的产品里,因为客户环境不允许动态编译带来的版本复杂度,我们最终采用的是混合模式:常规业务变更走发布流程,迁移文件在编译期生成并随产品发布;仅保留了一个“诊断模式”入口,允许实施人员在同一版本里针对新挂载的模块执行一次运行时迁移。运行时会先尝试读取模块程序集的 MigrationsFolder,把模块自带的迁移应用掉;如果模块需要数据库里追加自定义项,再回到“生成后落盘待下次重启应用”的处理,而不是硬塞热加载。
这样一个折中方案既保留了自动化升级的能力,也把故障边界控制在可接受的范围内。编程方式生成迁移这件事,真正难的地方永远不是调用那几行 API,而是你要想清楚生成后的产物在整套软件交付链里承担什么角色。把这个问题想明白,代码怎么写都是顺理成章的事。
