项目

微服务解决方案:数据库配置

您必须拥有 ABP Business 或更高级别的许可证,才能创建微服务解决方案。

本文档解释了在 ABP Studio 微服务解决方案模板中设计和实现的数据库配置与迁移结构。

数据库配置

微服务解决方案被设计为拥有多个数据库。通常,每个微服务都有自己的数据库。服务负责定义、配置和迁移自己的数据库。

本文档主要关注 Entity Framework 配置。同时也会指出与 MongoDB 的差异。

DbContext 类

解决方案中的每个微服务都定义了一个 DbContext 类。以下示例取自 Identity 微服务:

[ConnectionStringName(DatabaseName)]
[ReplaceDbContext(
    typeof(IIdentityProDbContext),
    typeof(IOpenIddictDbContext)
    )]
public class IdentityServiceDbContext :
    AbpDbContext<IdentityServiceDbContext>,
    IIdentityProDbContext,
    IOpenIddictDbContext,
    IHasEventInbox,
    IHasEventOutbox
{
    public const string DbTablePrefix = "";
    public const string DbSchema = null;
    
    public const string DatabaseName = "Identity";
    
    public DbSet<IncomingEventRecord> IncomingEvents { get; set; }
    public DbSet<OutgoingEventRecord> OutgoingEvents { get; set; }
    
    /* 这些 DbSet 属性来自 Identity 模块 */
    public DbSet<IdentityUser> Users { get; set; }
    public DbSet<IdentityRole> Roles { get; set; }
    public DbSet<IdentityClaimType> ClaimTypes { get; set; }
    public DbSet<OrganizationUnit> OrganizationUnits { get; set; }
    public DbSet<IdentitySecurityLog> SecurityLogs { get; set; }
    public DbSet<IdentityLinkUser> LinkUsers { get; set; }
    public DbSet<IdentityUserDelegation> UserDelegations { get; set; }
    
    /* 这些 DbSet 属性来自 OpenIddict 模块 */
    public DbSet<OpenIddictApplication> Applications { get; set; }
    public DbSet<OpenIddictAuthorization> Authorizations { get; set; }
    public DbSet<OpenIddictScope> Scopes { get; set; }
    public DbSet<OpenIddictToken> Tokens { get; set; }

    public IdentityServiceDbContext(DbContextOptions<IdentityServiceDbContext> options) 
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        
        builder.ConfigureEventInbox();
        builder.ConfigureEventOutbox();
        builder.ConfigureIdentityPro();
        builder.ConfigureOpenIddictPro();
    }
}

让我们检查一下这个类。第一个重要的部分是 ConnectionStringName 属性:

[ConnectionStringName(DatabaseName)]

ConnectionStringName 属性 定义了该 DbContext 类所使用的连接字符串的唯一名称。它与 appsettings.json 文件中定义的连接字符串相匹配。该名称在数据库迁移中用于区分不同的数据库模式,并在多租户系统中存储租户连接字符串时用作键。因此,每个物理上独立的数据库都应像这里一样拥有唯一的连接字符串/数据库名称。

DatabaseName 常量在 DbContext 类中定义:

public const string DatabaseName = "Identity";

该类的第二个重要部分是 ReplaceDbContext 属性的用法:

[ReplaceDbContext(
    typeof(IIdentityProDbContext),
    typeof(IOpenIddictDbContext)
    )]

Identity 微服务利用了 IdentityOpenIddict 模块,并创建了一个包含这些模块数据库模式的单一数据库。这些模块通常定义自己的 DbContext 类。但是 ReplaceDbContext 属性 告诉 ABP 在运行时使用这个(IdentityServiceDbContextDbContext 类,而不是这些模块定义的 DbContext 类。从技术上讲,它在运行时替换了给定的 DbContext 类。我们这样做是为了确保当我们在处理这些多个模块时,拥有单一(合并的)数据库模式、单一数据库迁移路径和单一数据库事务操作。当我们替换 DbContext 时,我们应该实现它的接口,正如 IdentityServiceDbContext 类所做的那样:

public class IdentityServiceDbContext :
    AbpDbContext<IdentityServiceDbContext>,
    IIdentityProDbContext,
    IOpenIddictDbContext,
    IHasEventInbox,
    IHasEventOutbox
  • 该类实现了 IIdentityProDbContextIOpenIddictDbContext,因此这些模块可以使用它。
  • 它还实现了 IHasEventInboxIHasEventOutbox 接口,因此事务性的 [收件箱/发件箱模式](inbox/outbox 模式) 可以在发送和接收 分布式事件 时工作。

接下来,IdentityServiceDbContext 类定义了由所实现的接口强制要求的以下属性:

public DbSet<IncomingEventRecord> IncomingEvents { get; set; }
public DbSet<OutgoingEventRecord> OutgoingEvents { get; set; }

/* 这些 DbSet 属性来自 Identity 模块 */
public DbSet<IdentityUser> Users { get; set; }
public DbSet<IdentityRole> Roles { get; set; }
public DbSet<IdentityClaimType> ClaimTypes { get; set; }
public DbSet<OrganizationUnit> OrganizationUnits { get; set; }
public DbSet<IdentitySecurityLog> SecurityLogs { get; set; }
public DbSet<IdentityLinkUser> LinkUsers { get; set; }
public DbSet<IdentityUserDelegation> UserDelegations { get; set; }

/* 这些 DbSet 属性来自 OpenIddict 模块 */
public DbSet<OpenIddictApplication> Applications { get; set; }
public DbSet<OpenIddictAuthorization> Authorizations { get; set; }
public DbSet<OpenIddictScope> Scopes { get; set; }
public DbSet<OpenIddictToken> Tokens { get; set; }

最后,我们执行由 ABP 和模块提供的扩展属性,以便为它们配置实体映射:

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.ConfigureEventInbox();
    builder.ConfigureEventOutbox();
    builder.ConfigureIdentityPro();
    builder.ConfigureOpenIddictPro();
}

IDesignTimeDbContextFactory 实现

解决方案为每个 DbContext 类都有一个实现了 IDesignTimeDbContextFactory 的类。例如,Identity 微服务有以下类:

public class IdentityServiceDbContextFactory
    : IDesignTimeDbContextFactory<IdentityServiceDbContext>
{
    public IdentityServiceDbContext CreateDbContext(string[] args)
    {
        IdentityServiceEfCoreEntityExtensionMappings.Configure();
        
        var builder = new DbContextOptionsBuilder<IdentityServiceDbContext>()
        .UseSqlServer(GetConnectionStringFromConfiguration(), b =>
        {
            b.MigrationsHistoryTable("__IdentityService_Migrations");
        });
        
        return new IdentityServiceDbContext(builder.Options);
    }

    private static string GetConnectionStringFromConfiguration()
    {
        return BuildConfiguration()
            .GetConnectionString(IdentityServiceDbContext.DatabaseName)
               ?? throw new ApplicationException(
                    $"Could not find a connection string named
                    '{IdentityServiceDbContext.DatabaseName}'.");
    }

    private static IConfigurationRoot BuildConfiguration()
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: false);

        return builder.Build();
    }
}

IdentityServiceDbContextFactory 类在您使用 EF Core 命令行命令(如 dotnet ef migrations adddotnet ef database update)时,用于创建 IdentityServiceDbContext 实例。如果您在 Visual Studio 的包管理器控制台中使用 Add-MigrationUpdate-Database 命令,也会用到它。

这个类有两个重点:

  • b.MigrationsHistoryTable("__IdentityService_Migrations"); 调用设置了 EF Core 用于跟踪数据库模式迁移历史的表名。我们覆盖它是为了确保每个 DbContext 都有一个唯一的表名。如果您想将单个物理数据库用于多个微服务,或者以后合并服务的数据库,这尤其有用。
  • BuildConfiguration 方法使用 appsettings.json 文件创建配置,因此您不需要重复连接字符串和其他选项。

AbpDbConnectionOptions 配置

每个微服务(以及其他连接到数据库的应用程序)都配置 AbpDbConnectionOptions 选项类。这通常在服务(或应用程序)的模块类中定义的一个方法(名为 ConfigureDatabase)中完成。例如,Identity 微服务有一个 CloudCrmIdentityServiceModule 类,它定义了一个 ConfigureDatabase 方法:

private void ConfigureDatabase(ServiceConfigurationContext context)
{
    // ...
}

该方法配置了一些选项。在本节中,我们将重点介绍 AbpDbConnectionOptions 配置部分:

Configure<AbpDbConnectionOptions>(options =>
{
    options.Databases.Configure("Administration", database =>
    {
        database.MappedConnections
          .Add(AbpPermissionManagementDbProperties.ConnectionStringName);
        database.MappedConnections
          .Add(AbpFeatureManagementDbProperties.ConnectionStringName);
        database.MappedConnections
          .Add(AbpSettingManagementDbProperties.ConnectionStringName);
        database.MappedConnections
          .Add(LanguageManagementDbProperties.ConnectionStringName);
    });
            
    options.Databases.Configure("AuditLogging", database =>
    {
        database.MappedConnections
          .Add(AbpAuditLoggingDbProperties.ConnectionStringName);
    });
            
    options.Databases.Configure("Saas", database =>
    {
        database.MappedConnections
          .Add(SaasDbProperties.ConnectionStringName);
    });
            
    options.Databases.Configure(IdentityServiceDbContext.DatabaseName, database =>
    {
        database.MappedConnections
          .Add(AbpIdentityDbProperties.ConnectionStringName);
        database.MappedConnections
          .Add(AbpOpenIddictDbProperties.ConnectionStringName);
    });
});

该配置基本上定义了该服务/应用程序访问的不同数据库,并定义了模块数据库模式与物理数据库之间的映射关系

例如,权限管理、功能管理、设置管理和语言管理模块将使用 Administration 数据库,因为我们已将这些模块合并到 Administration 微服务中——我们不想为它们创建单独的服务和数据库。

类似地,我们正在映射并将 Identity 和 OpenIddict 模块的连接字符串重定向到由 IdentityServiceDbContext 定义的数据库。

定义数据库并将预构建的模块映射到这些数据库在运行时至关重要,因此应仔细配置。

AbpDbContextOptions 配置

ConfigureDatabase 方法然后配置 AbpDbContextOptions 选项类。来自 Identity 微服务的示例:

Configure<AbpDbContextOptions>(options =>
{
    options.Configure(opts =>
    {
        /* 为此服务设置默认的 DBMS */
        opts.UseSqlServer();
    });
            
    options.Configure<IdentityServiceDbContext>(c =>
    {
        c.UseSqlServer(b =>
        {
            b.MigrationsHistoryTable("__IdentityService_Migrations");
        });
    });     
});

我们基本上是将 SQL Server 设置为此服务(或应用程序)的默认数据库管理系统。然后我们覆盖 IdentityServiceDbContext 的配置,将迁移历史表设置为与 IdentityServiceDbContextFactory 类中定义的相匹配。

注册 DbContext

最后,ConfigureDatabase 方法将 IdentityServiceDbContext 类注册到依赖注入系统并对其进行配置:

context.Services.AddAbpDbContext<IdentityServiceDbContext>(options =>
{
    options.AddDefaultRepositories();
});

AddDefaultRepositories 用于为所有聚合根实体注册默认的仓储实现。

数据库迁移

当您使用关系数据库时,数据库模式(表、字段、视图等)必须在使用前显式创建。此外,每当您部署服务的新版本时,如果对数据库模式进行了更改,则应更新数据库模式。

例如,如果您向数据库表添加了新字段,则应在将服务部署到生产环境之前(或同时)将该字段也添加到生产数据库中。

在像微服务解决方案这样高度动态的系统中,手动管理模式更改不是一个好习惯,而且容易出错。微服务解决方案模板使用 Entity Framework 迁移来维护数据库模式,并在您部署服务/应用程序的新版本时自动迁移它。

除了模式更改之外,您可能还需要向某些表中插入一些初始(种子)数据,以便使您的服务器正常工作。这个过程称为数据种子。微服务解决方案也配置为可以在应用程序启动时种子化此类初始数据。

如果您使用 MongoDB 作为数据库提供程序,则不需要模式迁移(但是,如果您对数据库模式进行了破坏性更改,您应该注意某些类型的数据和模式迁移——这取决于您的应用程序,因此您应该了解如何使用像 MongoDB 这样的文档数据库)。但是,数据种子系统仍然用于向数据库插入初始数据。

服务启动时的数据库迁移

每个微服务都负责迁移自己的数据库模式。它们通过在服务启动时检查和应用数据库迁移来履行这一职责。

在本文档中,我们将以 Identity 微服务为例进行说明,但该文档对其他服务也有效。

例如,Identity 微服务的启动模块类重写了 OnPostApplicationInitializationAsync 方法来触发迁移操作:

public override async Task
    OnPostApplicationInitializationAsync(ApplicationInitializationContext context)
{
    using var scope = context.ServiceProvider.CreateScope();
    await scope.ServiceProvider
        .GetRequiredService<IdentityServiceRuntimeDatabaseMigrator>()
        .CheckAndApplyDatabaseMigrationsAsync();
}

在此示例中,IdentityServiceRuntimeDatabaseMigrator 类检查并应用待处理的更改(如果存在)。

public class IdentityServiceRuntimeDatabaseMigrator
    : EfCoreRuntimeDatabaseMigratorBase<IdentityServiceDbContext>
{
    private readonly IdentityServiceDataSeeder _identityServiceDataSeeder;

    /* 为保持简洁,省略了构造函数代码 */

    protected override async Task SeedAsync()
    {
        await _identityServiceDataSeeder.SeedAsync();
    }
}

由于迁移逻辑非常常见,ABP 提供了一个基类来实现基本的迁移逻辑。IdentityServiceRuntimeDatabaseMigrator 继承自 EfCoreRuntimeDatabaseMigratorBase 类,该类执行实际的迁移操作。

在这里,我们只是重写了 SeedAsync 方法,该方法在数据库模式迁移之后运行。如果您查看源代码,您将看到 IdentityServiceDataSeeder 创建了 admin 用户、admin 角色及其权限,因此您可以登录到应用程序。

让我们解释一下 EfCoreRuntimeDatabaseMigratorBase 的行为:

  • 首先,它会在出现任何故障时重试迁移操作。它总共尝试 3 次,然后重新抛出异常并导致应用程序崩溃。由于 Kubernetes(或 ABP Studio 解决方案运行器)会在崩溃时重新启动它,因此它将持续尝试直到成功。尤其是当服务启动时数据库服务器尚未准备就绪时,可能会出现临时性故障。在下次尝试之前,它会等待 5 到 15 秒之间的随机值。
  • 它使用分布式锁来确保迁移操作一次只能由一个服务实例执行。如果您运行服务的多个实例(这在微服务系统中很常见),这一点尤其重要。它使用基于 DatabaseName 属性的唯一分布式密钥名称,因此不同的数据库可以并行迁移。
  • 它使用 Entity Framework 的 API 来获取待处理迁移的列表,并在有待处理迁移时迁移数据库。
  • 然后,它通过调用虚拟的 SeedAsync 方法来为数据库播种。请记住,我们已经重写了该方法来为 identity 微服务种子化初始数据。
  • 最后,如果应用了数据库模式迁移,它会发布一个分布式事件 AppliedDatabaseMigrationsEto,并携带 DatabaseName。此事件随后被 SaaS 模块 用于在多租户系统中触发租户数据库的迁移。请参阅本文档中的租户的数据库迁移部分。

配置 EfCoreRuntimeDatabaseMigratorBase

EfCoreRuntimeDatabaseMigratorBase 的工作方式针对常见场景进行了优化。但是,在某些情况下,您可能希望对其进行微调。

以下属性适合在派生类(对于 Identity 微服务是 IdentityServiceRuntimeDatabaseMigrator)的构造函数中更改:

  • AlwaysSeedTenantDatabases(默认值:false):如果您没有数据库模式更改,则不会触发 AppliedDatabaseMigrationsEto 事件。因此,如果您没有数据库模式更改,但有新的数据库种子,那么新的种子代码将不会应用于租户数据库。如果您希望即使没有数据库模式更改也始终应用种子,则可以将该属性设置为 true。但是,如果您有许多租户数据库,则在每次服务启动时运行数据库种子代码将非常低效。或者,您可以使用 EF Core 的 Add-Migration 命令添加一个空的数据库迁移来触发模式更改。您的迁移历史记录将不必要地增长,否则性能损失是更大的问题。
  • MinValueToWaitOnFailure(默认值:5000,单位:毫秒):在发生故障重试迁移之前的最小等待时间。
  • MaxValueToWaitOnFailure(默认值:15000,单位:毫秒):在发生故障重试迁移之前的最大等待时间。
  • DatabaseName(字符串):每个独立的数据库在系统中必须有一个唯一的名称,以便 EfCoreRuntimeDatabaseMigratorBase 可以针对每个数据库使用分布式锁定系统。该值在 IdentityServiceRuntimeDatabaseMigrator 类的构造函数中传递,并为预构建的服务进行了正确配置。另请参阅本文档中的数据库配置部分。

租户的数据库迁移

微服务解决方案允许为每个微服务为每个租户创建专用的物理数据库。如果您想为特定租户分配专用资源,或者出于安全、隔离或其他原因需要分离某些租户数据库,这可能很有用。

每个服务都有自己数据库的租户可能会极大地增加您的数据库数量,并可能使您的系统难以监控、备份和维护。这是一个严肃的系统决策,如果并非必需,我们建议避免这样做。

如果您为某个租户为某个微服务设置了单独的数据库,并且该微服务的数据库模式发生了变化,您也必须更改该租户的数据库模式。否则,当您的服务使用该租户的数据库时可能会失败。

如上所述,当 EfCoreRuntimeDatabaseMigratorBase 为微服务数据库应用数据库模式迁移时,它会发布一个分布式事件(AppliedDatabaseMigrationsEto)。然后 SaaS 模块会处理该事件。SaaS 模块随后查找所有为该微服务数据库拥有专用数据库的租户,并发布 ApplyDatabaseMigrationsEto 事件(每个租户一个)。ApplyDatabaseMigrationsEto 事件随后由相关的微服务处理,以为给定的租户迁移相关数据库。这是在微服务项目中的 IdentityServiceDatabaseMigrationEventHandler 完成的。IdentityServiceDatabaseMigrationEventHandler 继承自 EfCoreDatabaseMigrationEventHandlerBase 类,该类实现了实际的迁移逻辑。

EfCoreDatabaseMigrationEventHandlerBase 处理三种类型的事件:

  • TenantCreatedEto:创建新租户时发布。
  • TenantConnectionStringUpdatedEto:租户的连接字符串发生更改时发布。
  • ApplyDatabaseMigrationsEto:如上所述,当需要迁移租户数据库时发布。

对于所有这些事件,我们需要为租户迁移相关数据库(如果数据库不存在,迁移系统会创建初始数据库)。迁移逻辑如下:

  • 使用 ICurrentTenant.Change 方法 切换到相关的租户上下文。
  • 如果给定的租户为当前微服务拥有专用的连接字符串,则迁移数据库模式并种子化初始数据。
  • 如果失败,它会通过忽略错误并重新发布正在处理的事件,最多重试 3 次,每次等待 5 到 15 秒之间的随机时间。

连接字符串更新

如上一节所述,当您更改租户的连接字符串时,迁移系统会自动创建并种子化新数据库。但是,如果旧数据库中有数据,它不会移动数据。您需要自己处理。您可以重写 AfterTenantConnectionStringUpdated 方法来采取必要的操作。

失败处理

如前所述,迁移系统在失败时最多重试 3 次,每次等待 5 到 15 秒之间的随机时间。您可以通过设置 MinValueToWaitOnFailureMaxValueToWaitOnFailure(单位:毫秒)来更改这些持续时间。默认尝试次数可以通过设置 MaxEventTryCount 值来更改。

如果达到最大尝试次数(MaxEventTryCount),则重试机制停止。在这种情况下,租户数据库将保持旧状态,因为它无法迁移。在这种情况下,预期系统管理员应理解问题(可能是连接字符串错误或目标数据库服务器暂时无法访问),并在问题解决后手动触发迁移。详见 SaaS 模块:租户管理 UI 部分。

SaaS 模块:租户管理 UI

SaaS 模块提供了必要的 UI 来为租户设置和更改连接字符串,并触发数据库迁移。

连接字符串管理模态框

您可以在 SaaS 模块的租户页面上,点击租户操作下拉按钮中的数据库连接字符串命令:

saas-module-tenant-actions

它将打开如下所示的数据库连接字符串模态框:

tenant-connection-string-management-modal

在这里,我们可以为租户设置一个默认连接字符串。如果您没有为特定的微服务数据库定义连接字符串,则默认连接字符串将用作回退值。

如果您只为租户设置默认连接字符串,则所有微服务都将为该租户使用该单一数据库。它们将在该数据库内创建自己的表,并在其上执行所有操作。我们建议采用这种方法,因为一个租户将拥有一个数据库,这更易于管理。

如果您想为租户的每个微服务设置一个数据库,请勾选使用模块特定的数据库连接字符串复选框,如下所示:

tenant-connection-string-management-modal-with-separate-modules

在这里,您可以为每个微服务设置不同的连接字符串。如果您没有为其中一个服务定义连接字符串,它将使用上面的默认连接字符串。如果您没有定义默认连接字符串,则将使用相关微服务的主数据库。

每个服务都有自己数据库的租户可能会极大地增加您的数据库数量,并可能使您的系统难以监控、备份和维护。这是一个严肃的系统决策,如果并非必需,我们建议避免这样做。

当您进行更改并保存对话框时,必要的数据库会自动创建和迁移。如果您稍后更新连接字符串(例如,如果更改数据库名称),它也会再次触发数据库迁移过程。

手动应用数据库迁移

如果您需要为特定租户手动触发数据库迁移,请在 SaaS 模块的租户管理页面上,点击相关租户的操作下拉菜单,然后选择应用数据库迁移命令:

apply-tenant-migrations-command

请参阅上面的失败处理部分,以了解为什么可能需要手动触发此操作。

系统启动时的快速故障

当您最初在开发环境运行应用程序,或首次将解决方案部署到生产环境时,您的服务可能在启动时快速失败。这是因为它们需要在启动时进行一些数据库操作,但数据库尚未创建。它们将在相关服务的首次运行时创建。

例如,如果 Administration 服务没有提前启动并创建数据库,Identity 微服务将在启动时失败。所有这些都是预期内的,无需担心。这是分布式系统的本质。

该解决方案的设计能够容忍这些启动故障,并且一切都会在几秒或几分钟内开始正常工作。ABP Studio 解决方案运行器和 Kubernetes 都有系统来重启失败的服务,因此服务将重新启动并重试,直到其启动依赖项得到满足。


在本文档中