多租户
许多业务线应用程序用于与多个客户合作。 在这种情况下务必要做好数据保护工作,以防止客户数据“泄露”或被其他客户和潜在竞争对手看到。 这些应用程序被归类为“多租户”应用程序,因为每个客户都被视为该应用程序的一个租户且拥有自己的数据集。
重要
本文档按“原样”提供了示例和解决方案。这些做法不是“最佳做法”,而是可供考虑的“工作实践”。
提示
可以在 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 不能直接支持此方案,不建议使用此解决方案。
在另一种方法中,同一数据库可以使用表架构处理 tenant1
和 tenant2
。
- 租户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 应用中实现多租户的工作指南。 如果你有其他示例或场景,或者希望提供反馈,请提交问题并参考本文档。