多租户

许多业务线应用程序旨在与多个客户合作。 保护数据非常重要,这样客户数据就不会被其他客户和潜在竞争对手“泄露”或看到。 这些应用程序被归类为“多租户”,因为每个客户都被视为具有其自己的数据集的应用程序租户。

重要

本文档提供“原样”的示例和解决方案。这些做法不是“最佳做法”,而是要考虑的“工作做法”。

提示

可以在 GitHub 上查看此示例的源代码

支持多租户

在应用程序中实现多租户的方法有很多。 (的一种常见方法是,有时要求) 将每个客户的数据保存在单独的数据库中。 架构相同,但数据特定于客户。 另一种方法是按客户对现有数据库中的数据进行分区。 这可以通过使用表中的列或具有具有每个租户架构的多个架构中的表来完成。

方法 租户列? 每个租户的架构? 多个数据库? EF Core 支持
歧视性 (列) 全局查询筛选器
每个租户一个数据库 配置
每个租户的架构 不支持

对于每个租户的数据库方法,切换到正确的数据库与提供正确的连接字符串一样简单。 将数据存储在单个数据库中时, 可以使用全局查询筛选器 按租户 ID 列自动筛选行,确保开发人员不会意外编写代码来访问其他客户的数据。

这些示例应在大多数应用模型中正常工作,包括控制台、WPF、WinForms 和 ASP.NET Core应用。 Blazor 服务器应用需要特别考虑。

Blazor 服务器应用和工厂的生活

Blazor 应用中使用 Entity Framework Core 的建议模式是注册 DbContextFactory,然后调用它来创建每个操作的新实例 DbContext 。 默认情况下,工厂是 单一实例 ,因此应用程序的所有用户仅存在一个副本。 这通常很好,因为虽然工厂是共享的,但各个 DbContext 实例不是。

但是,对于多租户,连接字符串可能会更改每个用户。 由于工厂缓存具有相同生存期的配置,这意味着所有用户必须共享相同的配置。 因此,生存期应更改为 Scoped

Blazor WebAssembly 应用中不会发生此问题,因为单一实例的范围限定为用户。 另一方面,Blazor Server 应用提出了独特的挑战。 虽然应用是 Web 应用,但通过使用 SignalR 进行实时通信,它“保持活动状态”。 每个用户创建一个会话,并且超过初始请求。 应为每个用户提供一个新工厂,以允许新设置。 此特殊工厂的生存期限定为作用域,并为每个用户会话创建一个新实例。

单一数据库) 单一数据库 (示例解决方案

可能的解决方案是创建一个简单的 ITenantService 服务,用于处理设置用户的当前租户。 它提供回调,以便在租户更改时通知代码。 实现 (省略回调,以便清楚起见,) 如下所示:

namespace Common
{
    public interface ITenantService
    {
        string Tenant { get; }

        void SetTenant(string tenant);

        string[] GetTenants();

        event TenantChangedEventHandler OnTenantChanged;
    }
}

DbContext然后,可以管理多租户。 此方法取决于数据库策略。 如果要将所有租户存储在单个数据库中,则可能要使用查询筛选器。 该 ITenantService 函数通过依赖项注入传递到构造函数,用于解析和存储租户标识符。

public ContactContext(
    DbContextOptions<ContactContext> opts,
    ITenantService service)
    : base(opts) => _tenant = service.Tenant;

重写 OnModelCreating 该方法以指定查询筛选器:

protected override void OnModelCreating(ModelBuilder modelBuilder)
    => modelBuilder.Entity<MultitenantContact>()
        .HasQueryFilter(mt => mt.Tenant == _tenant);

这可确保每个请求将每个查询筛选到租户。 无需在应用程序代码中进行筛选,因为全局筛选器将自动应用。

租户提供程序, DbContextFactory 并在应用程序启动中配置,如下所示,使用 Sqlite 作为示例:

builder.Services.AddDbContextFactory<ContactContext>(
    opt => opt.UseSqlite("Data Source=singledb.sqlite"), ServiceLifetime.Scoped);

请注意, 服务生存期 配置为使用 ServiceLifetime.Scoped。 这使它能够依赖租户提供程序。

注意

依赖项必须始终流向单一实例。 这意味着服务Scoped可以依赖于其他Scoped服务或服务Singleton,但Singleton服务只能依赖于其他Singleton服务: Transient => Scoped => Singleton

多个架构

警告

EF Core 不支持此方案,不建议使用解决方案。

在不同的方法中,同一数据库可以处理 tenant1 和使用 tenant2 表架构。

  • Tenant1 - tenant1.CustomerData
  • Tenant2 - tenant2.CustomerData

如果不使用 EF Core 处理迁移的数据库更新,并且已有多架构表,则可以替代类似于下面的架构 (OnModelCreatingCustomerData的架构DbContext设置为租户) :

protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.Entity<CustomerData>().ToTable(nameof(CustomerData), tenant);

多个数据库和连接字符串

通过为每个租户传递不同的连接字符串来实现多个数据库版本。 可以通过解析服务提供商并使用它来生成连接字符串,在启动时配置此设置。 按租户部分添加连接字符串。appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "TenantA": "Data Source=tenantacontacts.sqlite",
    "TenantB": "Data Source=tenantbcontacts.sqlite"
  },
  "AllowedHosts": "*"
}

服务和配置都注入到 DbContext

public ContactContext(
    DbContextOptions<ContactContext> opts,
    IConfiguration config,
    ITenantService service)
    : base(opts)
{
    _tenantService = service;
    _configuration = config;
}

然后,租户用于在以下项中 OnConfiguring查找连接字符串:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var tenant = _tenantService.Tenant;
    var connectionStr = _configuration.GetConnectionString(tenant);
    optionsBuilder.UseSqlite(connectionStr);
}

这适用于大多数情况,除非用户可以在同一会话期间切换租户。

切换租户

在以前的多个数据库的配置中,选项将缓存在 Scoped 级别。 这意味着,如果用户更改了租户, 则不会 重新计算选项,因此租户更改不会反映在查询中。

当租户 可以 更改时,可以轻松解决此问题,即设置生存期, Transient. 这可确保每次请求租户时 DbContext 都会重新评估租户以及连接字符串。 用户可以尽可能频繁地切换租户。 下表可帮助你选择哪种生存期对工厂最有意义。

方案 单一数据库 多个数据库
用户停留在单个租户中 Scoped Scoped
用户可以切换租户 Scoped Transient

如果数据库不采用用户范围的依赖项,则默认值 Singleton 仍然有意义。

性能说明

EF Core 的设计使 DbContext 实例可以快速实例化,并尽可能少的开销。 因此,创建每个操作的新 DbContext 操作通常应该很好。 如果此方法会影响应用程序的性能,请考虑使用 DbContext 池

结束语

这是在 EF Core 应用中实现多租户的工作指南。 如果有进一步的示例或方案或希望提供反馈,请 打开问题 并参考本文档。