面向 .NET Core 的 FluentMigrator 控制器和服务






3.80/5 (3投票s)
通过 Web API 端点管理数据库迁移。
目录
引言
我认为 FluentMigrator 是一个对数据库进行修订的绝佳工具。它易于学习的“流畅”语法,可以满足我 90% 的需求,如果有一些特殊的需求,它提供了一个 `Execute` 方法用于自定义 SQL 操作。由于我发现提供 Web API 端点来执行迁移和检查迁移版本非常有用,所以本文将介绍如何在 .NET 6 中实现这一点。相同的控制器和服务也可以与 .NET Core 3.1 配合使用,唯一的区别在于程序/启动配置。
一个迁移示例
Fluent Migrator 的语法在其 入门页面 上有描述,因此这里我只提供一个简单的“向上”和“向下”迁移示例。
using FluentMigrator;
namespace Clifton
{
[Migration(202201011201)]
public class _202201011201_CreateTables : Migration
{
public override void Up()
{
Create.Table("Test")
.WithColumn("Id").AsInt32().PrimaryKey().Identity().NotNullable()
.WithColumn("IntField").AsInt32().Nullable()
.WithColumn("StringField").AsString().Nullable()
.WithColumn("DateField").AsDate().Nullable()
.WithColumn("DateTimeField").AsDateTime().Nullable()
.WithColumn("TimeField").AsTime().Nullable()
.WithColumn("BitField").AsBoolean().Nullable()
.WithColumn("Deleted").AsBoolean().NotNullable();
}
public override void Down()
{
Delete.Table("Test");
}
}
}
一些最佳实践
- 使用 yyyyMMddhhmm 格式对迁移版本进行编号,这样可以使您的迁移保持顺序。
- 为了帮助组织一个生命周期长且随着时间推移而不断改进的产品迁移,请考虑为年份添加一个文件夹,并为每一年中的每个月添加子文件夹。
- 理想情况下,一次迁移应该只操作一个表或视图。当然,可以进行多个操作,例如创建列,但请考虑将涉及多个表的迁移写成单独的迁移。这样做的主要原因是可以帮助隔离哪个迁移失败了。
- 我并不信奉编写“向下”迁移——我很少,甚至从未需要回滚到某个迁移。然而,您的用例可能有所不同。
- 为您的迁移文件起一个独特的名称,描述迁移的原因。这是保持涉及多个表的迁移分开的另一个好理由,因为修改一个表的原因可能与其他表不同。
代码
添加 Fluent Migrator 需要一点点工作。我更喜欢将迁移放在一个单独的程序集中,而不是主 Web API 应用程序中。我还想捕获任何错误,奇怪的是,Fluent Migrator 使这个过程并不容易——我无法弄清楚如何添加一个除 Fluent Migrator 提供的之外的日志记录器,而且人们可能会认为他们至少会提供一个流日志记录器!Fluent Migrator 缺少的功能是创建数据库的能力,所以您会看到如何单独实现这一点。
包依赖项
使用的包如下
Dapper、System.Data.SqlClient
和 Newtonsoft.Json 主要是一次性的,原因如下:
Dapper
- 仅为方便检查数据库是否已存在以及在不存在时创建它System.Data.SqlClient
- 因为这是Dapper
使用的Newtonsoft.Json
- 因为Newtonsoft.Json
比System.Text.Json
好得多
程序启动
我需要一些时间来适应 .NET 6。我做的第一件事是在 * .csproj* 文件中禁用了那个可空类型噩梦
<Nullable>disable</Nullable>
我还在适应隐式 using
语句以及没有 namespace
和 Main
方法。话虽如此,这是 *Program.cs* 文件
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using FluentMigrator.Runner;
using Newtonsoft.Json;
using Clifton;
using Interfaces;
var builder = WebApplication.CreateBuilder(args);
var appSettings = new AppSettings();
builder.Configuration.Bind(appSettings);
builder.Services.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.DefaultValueHandling = DefaultValueHandling.Ignore;
options.SerializerSettings.Formatting = Formatting.Indented;
});
var connection = builder.Configuration.GetConnectionString(appSettings.UseDatabase);
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connection));
builder.Services.AddScoped<IMigratorService, MigratorService>();
string migrationAssemblyPath = Path.Combine
(appSettings.ExecutingAssembly.Location.LeftOfRightmostOf("\\"), appSettings.MigrationAssembly);
Assembly migrationAssembly = Assembly.LoadFrom(migrationAssemblyPath);
builder.Services.AddFluentMigratorCore()
.ConfigureRunner(rb => rb
.AddSqlServer()
.WithGlobalConnectionString(connection)
.ScanIn(migrationAssembly).For.Migrations())
.AddLogging(lb => lb.AddFluentMigratorConsole());
var app = builder.Build();
app.UseAuthorization();
app.MapControllers();
app.Run();
除了样板代码,这里我们看到我添加了 NewtonsoftJson
控制器,我这样做是为了设置一些选项,包括缩进格式,因此对于本文而言,返回的 JSON 在浏览器中格式美观。
我们还看到添加了 MigratorService
以及 FluentMigratorCore
服务及其配置。
注意 ScanIn
调用——这很重要,因为它告诉 Fluent Migrator 要扫描哪个程序集以查找实现 Migration
属性和基类的类。
应用程序设置
配置来自 *appsettings.json* 文件,因此我们有一个 AppSettings
类,JSON 配置将绑定到该类
using System.Reflection;
namespace Clifton
{
public class AppSettings
{
public static AppSettings Settings { get; set; }
public string UseDatabase { get; set; }
public string MigrationAssembly { get; set; }
public Assembly ExecutingAssembly => Assembly.GetExecutingAssembly();
public AppSettings()
{
Settings = this;
}
}
}
在 *appsettings.json* 中,我们有这些声明
"UseDatabase": "DefaultConnection",
"MigrationAssembly": "Migrations.dll",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=Test;Integrated Security=True;",
"MasterConnection": "Server=localhost;Database=master;Integrated Security=True;"
}
UseDatabase
:以防您想支持不同的数据库连接,用于测试、开发、生产等。MigrationAssembly
:包含迁移的程序集的名称。MasterConnection
:在 migrator 服务中硬编码,用于检查数据库是否存在以及在不存在时创建它。
控制器
控制器实现了
- 一个迁移向上终结点
- 一个迁移向下终结点
- 一个列出所有迁移的终结点
- 一个获取控制器/服务版本的终结点,我觉得这很有用,只是为了确保 API 正常工作
using Microsoft.AspNetCore.Mvc;
using Interfaces;
namespace Clifton
{
[ApiController]
[Route("[controller]")]
public class MigratorController : ControllerBase
{
private readonly IMigratorService ms;
private readonly AppDbContext context;
public MigratorController(IMigratorService ms, AppDbContext context)
{
this.ms = ms;
this.context = context;
}
[HttpGet]
public ActionResult Version()
{
return Ok(new { Version = "1.00" });
}
[HttpGet("VersionInfo")]
public ActionResult VersionInfo()
{
var recs = context.VersionInfo.OrderByDescending(v => v.Version);
return Ok(recs);
}
[HttpGet("MigrateUp")]
public ActionResult MigrateUp()
{
var resp = ms.MigrateUp();
return Ok(resp);
}
[HttpGet("MigrateDown/{version}")]
public ActionResult MigrateDown(long version)
{
var resp = ms.MigrateDown(version);
return Ok(resp);
}
}
}
服务
该服务实现了迁移向上和迁移向下终结点的迁移行为。
using System.Data.SqlClient;
using System.Text;
using Dapper;
using FluentMigrator.Runner;
using Interfaces;
namespace Clifton
{
public class MigratorService : IMigratorService
{
private IMigrationRunner runner;
private IConfiguration cfg;
public MigratorService(IMigrationRunner runner, IConfiguration cfg)
{
this.runner = runner;
this.cfg = cfg;
}
public string MigrateUp()
{
EnsureDatabase();
var errs = ConsoleHook(() => runner.MigrateUp());
var result = String.IsNullOrEmpty(errs) ? "Success" : errs;
return result;
}
// Migrate down *to* the version.
// If you want to migrate down the first migration,
// use any version # prior to that first migration.
public string MigrateDown(long version)
{
var errs = ConsoleHook(() => runner.MigrateDown(version));
var result = String.IsNullOrEmpty(errs) ? "Success" : errs;
return result;
}
private void EnsureDatabase()
{
var cs = cfg.GetConnectionString(AppSettings.Settings.UseDatabase);
var dbName = cs.RightOf("Database=").LeftOf(";");
var master = cfg.GetConnectionString("MasterConnection");
var parameters = new DynamicParameters();
parameters.Add("name", dbName);
using var connection = new SqlConnection(master);
var records = connection.Query
("SELECT name FROM sys.databases WHERE name = @name", parameters);
if (!records.Any())
{
connection.Execute($"CREATE DATABASE [{dbName}]");
}
}
private string ConsoleHook(Action action)
{
var saved = Console.Out;
var sb = new StringBuilder();
var tw = new StringWriter(sb);
Console.SetOut(tw);
try
{
action();
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
tw.Close();
// Restore the default console out.
Console.SetOut(saved);
var errs = sb.ToString();
return errs;
}
}
}
上述代码中值得关注的是
EnsureDatabase
方法,它查询系统表databases
以查看数据库是否存在,并在不存在时创建它。- 控制台钩子,它将控制台输出捕获到一个流中,然后写入一个
StringBuilder
。 - 奇怪的是,有些错误由 Fluent Migrator 处理而不会抛出异常,而另一些错误则会抛出异常,至少从我所见的来看是这样。因此,异常处理器会将异常消息写入控制台,以便被
StringBuilder
流捕获。在旧版本的 Fluent Migrator 中,曾经有一种抑制异常的方法,但我找不到那个配置选项了。
查看 Fluent Migrator 的实际应用
迁移向上
使用本文开头的示例迁移,我们可以使用终结点将数据库更新到最新的迁移(好吧,我们只有一个)(您的端口在 Visual Studio 中可能不同)
localhost:5000/migrator/migrateup
我们看到
查看迁移
我们可以使用以下方式检查迁移(同样,只有一个)
localhost:5000/migrator/versioninfo
我们看到
是的,我们看到 Test
数据库和 Test
表被创建了
另外请注意,VersionInfo
表是由 Fluent Migrator 自动创建的。
是的,Test
表中的列也被创建了
迁移向下
我们也可以迁移到特定的版本。如果我们想迁移到*第一个迁移之前*,我们只需使用一个更早的迁移版本号
https://:5000/migrator/migratedown/202101011201
在 SSMS 中刷新表,我们看到 Test
表已被移除
错误报告
错误不是作为异常报告,而是简单地作为返回字符串报告。例如,在这里我删除了 VersionInfo
记录,以便 Fluent Migrator 认为迁移尚未运行,但表已存在,这就强制产生了一个错误
您可能希望将成功和错误状态包装在一个实际的 JSON 对象中。
结论
将数据库迁移实现为 Web API 中的一个终结点,可以轻松运行迁移,而不是运行一个单独的迁移应用程序。这在所有环境中都很有用——无论是在本地托管的开发环境,还是测试、QA 和生产环境。应该注意的是,人们可能会为控制器终结点添加身份验证/授权——您肯定不希望有人不小心将生产数据库完全迁移到第 0 天!
历史
- 2022年2月5日:初始版本