多租户

许多业务线应用程序用于与多个客户合作。 在这种情况下务必要做好数据保护工作,以防止客户数据“泄露”或被其他客户和潜在竞争对手看到。 这些应用程序被归类为“多租户”应用程序,因为每个客户都被视为该应用程序的一个租户且拥有自己的数据集。

警告

本文使用不需要对用户进行身份验证的本地数据库。 生产应用应使用可用的最安全的身份验证流。 有关已部署测试和生产应用的身份验证的详细信息,请参阅安全身份验证流

重要

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

提示

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

支持多租户

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

方法 使用租户列? 使用每租户架构? 使用多个数据库? EF Core 支持
鉴别器(列) 全局查询筛选器
每个租户一个数据库 No 配置
每租户架构 不支持

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

这些示例在大多数应用模型中应该能够正常工作,包括控制台、WPF、WinForms 和 ASP.NET Core 应用。 Blazor Server 应用需要特别注意。

Blazor Server 应用和工厂的寿命

在 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 不能直接支持此方案,不建议使用此解决方案。

在另一种方法中,同一数据库可以使用表架构处理 tenant1tenant2

  • 租户1 - tenant1.CustomerData
  • 租户2 - tenant2.CustomerData

如果你不使用 EF Core 通过迁移来处理数据库更新,并且已有多架构表,则可以如下所示在 OnModelCreating 中的 DbContext 中覆盖架构(表 CustomerData 的架构设置为租户):

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 应用中实现多租户的工作指南。 如果你有其他示例或场景,或者希望提供反馈,请提交问题并参考本文档。