使用 DbContext 和 TenantId 的多租户 - 拦截器、过滤器、EF Code-First
MultiTenancy with DbContext and TenantId - Interceptors, Filters, EF Code-First
我的组织需要共享数据库、共享架构多租户数据库。我们将根据 TenantId 进行查询。我们将拥有很少的租户(少于 10 个),并且所有租户都将共享相同的数据库模式,不支持 tenant-specific 更改或功能。租户元数据将存储在内存中,而不是数据库(静态成员)中。
这意味着所有实体现在都需要一个 TenantId,并且 DbContext
需要知道默认情况下对此进行过滤。
TenantId
可能由 header 值或原始域标识,除非有更可取的方法。
我已经看到各种为此利用拦截器的示例,但还没有看到关于 TenantId 实现的明确示例。
我们需要解决的问题:
- 我们如何修改当前架构以支持此功能(我认为很简单,只需添加 TenantId)
- 我们如何检测租户(同样简单 - 基于原始请求的域或 header 值 - 从 BaseController 中提取)
- 我们如何将其传播到服务方法(有点棘手...我们使用 DI 通过构造函数进行水合...希望避免在所有方法签名中添加
tenantId
)
- 我们如何修改 DbContext 以在我们拥有此 tenantId 后对其进行过滤(不知道)
- 我们如何优化性能。我们需要什么索引,我们如何确保查询缓存不会对 tenantId 隔离做任何奇怪的事情,等等(不知道)
- 身份验证 - 使用 SimpleMembership,我们如何隔离
User
s,以某种方式将它们与租户相关联。
我认为最大的问题是 4 - 修改 DbContext。
我喜欢这篇文章如何利用 RLS,但我不确定如何以 code-first、dbContext 方式处理它:
我会说我正在寻找的是一种方法 - 考虑到性能 - 使用 DbContext 选择性地查询 tenantId-isolated 资源,而不用 "AND TenantId = 1"
来调用我的调用等等
更新 - 我找到了一些选项,但我不确定每个选项的优缺点,或者是否有一些 "better" 方法。我对选项的评估归结为:
- 易于实施
- 性能
方法 A
这似乎是 "expensive" 因为每次我们新建一个 dbContext 时,我们都必须 re-initialize 过滤器:
首先,我设置我的租户和界面:
public static class Tenant {
public static int TenantA {
get { return 1; }
}
public static int TenantB
{
get { return 2; }
}
}
public interface ITenantEntity {
int TenantId { get; set; }
}
我在任何实体上实现了该接口:
public class Photo : ITenantEntity
{
public Photo()
{
DateProcessed = (DateTime) SqlDateTime.MinValue;
}
[Key]
public int PhotoId { get; set; }
[Required]
public int TenantId { get; set; }
}
然后更新我的 DbContext 实现:
public AppContext(): base("name=ProductionConnection")
{
Init();
}
protected internal virtual void Init()
{
this.InitializeDynamicFilters();
}
int? _currentTenantId = null;
public void SetTenantId(int? tenantId)
{
_currentTenantId = tenantId;
this.SetFilterScopedParameterValue("TenantEntity", "tenantId", _currentTenantId);
this.SetFilterGlobalParameterValue("TenantEntity", "tenantId", _currentTenantId);
var test = this.GetFilterParameterValue("TenantEntity", "tenantId");
}
public override int SaveChanges()
{
var createdEntries = GetCreatedEntries().ToList();
if (createdEntries.Any())
{
foreach (var createdEntry in createdEntries)
{
var isTenantEntity = createdEntry.Entity as ITenantEntity;
if (isTenantEntity != null && _currentTenantId != null)
{
isTenantEntity.TenantId = _currentTenantId.Value;
}
else
{
throw new InvalidOperationException("Tenant Id Not Specified");
}
}
}
}
private IEnumerable<DbEntityEntry> GetCreatedEntries()
{
var createdEntries = ChangeTracker.Entries().Where(V => EntityState.Added.HasFlag(V.State));
return createdEntries;
}
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Filter("TenantEntity", (ITenantEntity tenantEntity, int? tenantId) => tenantEntity.TenantId == tenantId.Value, () => null);
base.OnModelCreating(modelBuilder);
}
最后,在调用 DbContext 时,我使用了这个:
using (var db = new AppContext())
{
db.SetTenantId(someValueDeterminedElsewhere);
}
我对此有疑问,因为我在大约一百万个地方新建了我的 AppContext(一些服务方法需要它,一些不需要)- 所以这使我的代码有点膨胀。还有关于租户确定的问题——我是否传递 HttpContext,我是否强制我的控制器将 TenantId 传递到所有服务方法调用中,我如何处理我没有原始域的情况(webjob 调用等)。
方法 B
在此处找到:http://howtoprogram.eu/question/n-a,28158
看起来很相似,但很简单:
public interface IMultiTenantEntity {
int TenantID { get; set; }
}
public partial class YourEntity : IMultiTenantEntity {}
public partial class YourContext : DbContext
{
private int _tenantId;
public override int SaveChanges() {
var addedEntities = this.ChangeTracker.Entries().Where(c => c.State == EntityState.Added)
.Select(c => c.Entity).OfType<IMultiTenantEntity>();
foreach (var entity in addedEntities) {
entity.TenantID = _tenantId;
}
return base.SaveChanges();
}
public IQueryable<Code> TenantCodes => this.Codes.Where(c => c.TenantID == _tenantId);
}
public IQueryable<YourEntity> TenantYourEntities => this.YourEntities.Where(c => c.TenantID == _tenantId);
尽管这看起来像是具有相同问题的 A 的愚蠢版本。
我认为到这个时候,必须有一个成熟的、可取的configuration/architecture来满足这个需求。我们应该怎么做?
我想建议以下方法,
1. 为包含核心业务数据的每个 table 创建一个名称为租户 ID 的列,这对于任何映射 table.
都不是必需的
- 通过创建 returns 和
IQueryable
的扩展方法,使用方法 B。这个方法可以是 dbset 的扩展,这样任何编写过滤器子句的人都可以调用这个扩展方法,然后调用谓词。这将使开发人员更容易编写代码,而无需担心租户 ID 过滤器。此特定方法将包含根据执行此查询的租户上下文为租户 ID 列应用过滤条件的代码。
样本
ctx.TenantFilter().Where(....)
您可以在所有服务方法中传递租户 ID,而不是依赖于 http 上下文,这样可以轻松处理 Web 和 Web 工作应用程序中的租户联系人。这使得通话免于接触,更容易 testable。多租户实体接口方法看起来不错,我们的应用程序中确实有类似的限制,但到目前为止运行良好。
关于添加索引,您需要在具有租户 ID 的 table 中为租户 ID 列添加一个索引,并且应该处理数据库端查询索引部分。
关于身份验证部分,我建议将 asp.net identity 2.0 与 owin 管道一起使用。该系统具有很强的可扩展性和可定制性,并且如果将来需要,很容易与任何外部身份提供者集成。
请查看 entity framework 的存储库模式,它使您能够以通用方式编写更少的代码。这将帮助我们摆脱代码重复和冗余,并且非常容易从单元测试用例中进行测试
I think the biggest question there is 4 - modifying DbContext.
不要修改上下文...
您不必将租户过滤代码与您的业务代码混合使用。
我认为您只需要一个包含 returns 过滤数据的存储库
此存储库将 return 根据您从 TenantIdProvider 获得的 Id 过滤数据。
然后,您的服务不必知道有关租户的任何信息
using System;
using System.Data.Entity;
using System.Linq;
namespace SqlServerDatabaseBackup
{
public class Table
{
public int TenantId { get; set; }
public int TableId { get; set; }
}
public interface ITentantIdProvider
{
int TenantId();
}
public class TenantRepository : ITenantRepositoty
{
private int tenantId;
private ITentantIdProvider _tentantIdProvider;
private TenantContext context = new TenantContext(); //You can abstract this if you want
private DbSet<Table> filteredTables;
public IQueryable<Table> Tables
{
get
{
return filteredTables.Where(t => t.TenantId == tenantId);
}
}
public TenantRepository(ITentantIdProvider tentantIdProvider)
{
_tentantIdProvider = tentantIdProvider;
tenantId = _tentantIdProvider.TenantId();
filteredTables = context.Tables;
}
public Table Find(int id)
{
return filteredTables.Find(id);
}
}
public interface ITenantRepositoty
{
IQueryable<Table> Tables { get; }
Table Find(int id);
}
public class TenantContext : DbContext
{
public DbSet<Table> Tables { get; set; }
}
public interface IService
{
void DoWork();
}
public class Service : IService
{
private ITenantRepositoty _tenantRepositoty;
public Service(ITenantRepositoty tenantRepositoty)
{
_tenantRepositoty = tenantRepositoty;
}
public void DoWork()
{
_tenantRepositoty.Tables.ToList();//These are filtered records
}
}
}
问题是关于EF的,不过我觉得这里值得一提的是EF Core。在 EF Core 中,您可以使用 Global Query Filters
Such filters are automatically applied to any LINQ queries involving those Entity Types, including Entity Types referenced indirectly, such as through the use of Include or direct navigation property references
一个例子:
public class Blog
{
private string _tenantId;
public int BlogId { get; set; }
public string Name { get; set; }
public string Url { get; set; }
public List<Post> Posts { get; set; }
}
public class Post
{
public int PostId { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public bool IsDeleted { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().Property<string>("TenantId").HasField("_tenantId");
// Configure entity filters
modelBuilder.Entity<Blog>().HasQueryFilter(b => EF.Property<string>(b, "TenantId") == _tenantId);
modelBuilder.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);
}
我的组织需要共享数据库、共享架构多租户数据库。我们将根据 TenantId 进行查询。我们将拥有很少的租户(少于 10 个),并且所有租户都将共享相同的数据库模式,不支持 tenant-specific 更改或功能。租户元数据将存储在内存中,而不是数据库(静态成员)中。
这意味着所有实体现在都需要一个 TenantId,并且 DbContext
需要知道默认情况下对此进行过滤。
TenantId
可能由 header 值或原始域标识,除非有更可取的方法。
我已经看到各种为此利用拦截器的示例,但还没有看到关于 TenantId 实现的明确示例。
我们需要解决的问题:
- 我们如何修改当前架构以支持此功能(我认为很简单,只需添加 TenantId)
- 我们如何检测租户(同样简单 - 基于原始请求的域或 header 值 - 从 BaseController 中提取)
- 我们如何将其传播到服务方法(有点棘手...我们使用 DI 通过构造函数进行水合...希望避免在所有方法签名中添加
tenantId
) - 我们如何修改 DbContext 以在我们拥有此 tenantId 后对其进行过滤(不知道)
- 我们如何优化性能。我们需要什么索引,我们如何确保查询缓存不会对 tenantId 隔离做任何奇怪的事情,等等(不知道)
- 身份验证 - 使用 SimpleMembership,我们如何隔离
User
s,以某种方式将它们与租户相关联。
我认为最大的问题是 4 - 修改 DbContext。
我喜欢这篇文章如何利用 RLS,但我不确定如何以 code-first、dbContext 方式处理它:
我会说我正在寻找的是一种方法 - 考虑到性能 - 使用 DbContext 选择性地查询 tenantId-isolated 资源,而不用 "AND TenantId = 1"
来调用我的调用等等
更新 - 我找到了一些选项,但我不确定每个选项的优缺点,或者是否有一些 "better" 方法。我对选项的评估归结为:
- 易于实施
- 性能
方法 A
这似乎是 "expensive" 因为每次我们新建一个 dbContext 时,我们都必须 re-initialize 过滤器:
首先,我设置我的租户和界面:
public static class Tenant {
public static int TenantA {
get { return 1; }
}
public static int TenantB
{
get { return 2; }
}
}
public interface ITenantEntity {
int TenantId { get; set; }
}
我在任何实体上实现了该接口:
public class Photo : ITenantEntity
{
public Photo()
{
DateProcessed = (DateTime) SqlDateTime.MinValue;
}
[Key]
public int PhotoId { get; set; }
[Required]
public int TenantId { get; set; }
}
然后更新我的 DbContext 实现:
public AppContext(): base("name=ProductionConnection")
{
Init();
}
protected internal virtual void Init()
{
this.InitializeDynamicFilters();
}
int? _currentTenantId = null;
public void SetTenantId(int? tenantId)
{
_currentTenantId = tenantId;
this.SetFilterScopedParameterValue("TenantEntity", "tenantId", _currentTenantId);
this.SetFilterGlobalParameterValue("TenantEntity", "tenantId", _currentTenantId);
var test = this.GetFilterParameterValue("TenantEntity", "tenantId");
}
public override int SaveChanges()
{
var createdEntries = GetCreatedEntries().ToList();
if (createdEntries.Any())
{
foreach (var createdEntry in createdEntries)
{
var isTenantEntity = createdEntry.Entity as ITenantEntity;
if (isTenantEntity != null && _currentTenantId != null)
{
isTenantEntity.TenantId = _currentTenantId.Value;
}
else
{
throw new InvalidOperationException("Tenant Id Not Specified");
}
}
}
}
private IEnumerable<DbEntityEntry> GetCreatedEntries()
{
var createdEntries = ChangeTracker.Entries().Where(V => EntityState.Added.HasFlag(V.State));
return createdEntries;
}
protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
modelBuilder.Filter("TenantEntity", (ITenantEntity tenantEntity, int? tenantId) => tenantEntity.TenantId == tenantId.Value, () => null);
base.OnModelCreating(modelBuilder);
}
最后,在调用 DbContext 时,我使用了这个:
using (var db = new AppContext())
{
db.SetTenantId(someValueDeterminedElsewhere);
}
我对此有疑问,因为我在大约一百万个地方新建了我的 AppContext(一些服务方法需要它,一些不需要)- 所以这使我的代码有点膨胀。还有关于租户确定的问题——我是否传递 HttpContext,我是否强制我的控制器将 TenantId 传递到所有服务方法调用中,我如何处理我没有原始域的情况(webjob 调用等)。
方法 B
在此处找到:http://howtoprogram.eu/question/n-a,28158
看起来很相似,但很简单:
public interface IMultiTenantEntity {
int TenantID { get; set; }
}
public partial class YourEntity : IMultiTenantEntity {}
public partial class YourContext : DbContext
{
private int _tenantId;
public override int SaveChanges() {
var addedEntities = this.ChangeTracker.Entries().Where(c => c.State == EntityState.Added)
.Select(c => c.Entity).OfType<IMultiTenantEntity>();
foreach (var entity in addedEntities) {
entity.TenantID = _tenantId;
}
return base.SaveChanges();
}
public IQueryable<Code> TenantCodes => this.Codes.Where(c => c.TenantID == _tenantId);
}
public IQueryable<YourEntity> TenantYourEntities => this.YourEntities.Where(c => c.TenantID == _tenantId);
尽管这看起来像是具有相同问题的 A 的愚蠢版本。
我认为到这个时候,必须有一个成熟的、可取的configuration/architecture来满足这个需求。我们应该怎么做?
我想建议以下方法, 1. 为包含核心业务数据的每个 table 创建一个名称为租户 ID 的列,这对于任何映射 table.
都不是必需的- 通过创建 returns 和
IQueryable
的扩展方法,使用方法 B。这个方法可以是 dbset 的扩展,这样任何编写过滤器子句的人都可以调用这个扩展方法,然后调用谓词。这将使开发人员更容易编写代码,而无需担心租户 ID 过滤器。此特定方法将包含根据执行此查询的租户上下文为租户 ID 列应用过滤条件的代码。
样本
ctx.TenantFilter().Where(....)
您可以在所有服务方法中传递租户 ID,而不是依赖于 http 上下文,这样可以轻松处理 Web 和 Web 工作应用程序中的租户联系人。这使得通话免于接触,更容易 testable。多租户实体接口方法看起来不错,我们的应用程序中确实有类似的限制,但到目前为止运行良好。
关于添加索引,您需要在具有租户 ID 的 table 中为租户 ID 列添加一个索引,并且应该处理数据库端查询索引部分。
关于身份验证部分,我建议将 asp.net identity 2.0 与 owin 管道一起使用。该系统具有很强的可扩展性和可定制性,并且如果将来需要,很容易与任何外部身份提供者集成。
请查看 entity framework 的存储库模式,它使您能够以通用方式编写更少的代码。这将帮助我们摆脱代码重复和冗余,并且非常容易从单元测试用例中进行测试
I think the biggest question there is 4 - modifying DbContext.
不要修改上下文...
您不必将租户过滤代码与您的业务代码混合使用。
我认为您只需要一个包含 returns 过滤数据的存储库
此存储库将 return 根据您从 TenantIdProvider 获得的 Id 过滤数据。
然后,您的服务不必知道有关租户的任何信息
using System;
using System.Data.Entity;
using System.Linq;
namespace SqlServerDatabaseBackup
{
public class Table
{
public int TenantId { get; set; }
public int TableId { get; set; }
}
public interface ITentantIdProvider
{
int TenantId();
}
public class TenantRepository : ITenantRepositoty
{
private int tenantId;
private ITentantIdProvider _tentantIdProvider;
private TenantContext context = new TenantContext(); //You can abstract this if you want
private DbSet<Table> filteredTables;
public IQueryable<Table> Tables
{
get
{
return filteredTables.Where(t => t.TenantId == tenantId);
}
}
public TenantRepository(ITentantIdProvider tentantIdProvider)
{
_tentantIdProvider = tentantIdProvider;
tenantId = _tentantIdProvider.TenantId();
filteredTables = context.Tables;
}
public Table Find(int id)
{
return filteredTables.Find(id);
}
}
public interface ITenantRepositoty
{
IQueryable<Table> Tables { get; }
Table Find(int id);
}
public class TenantContext : DbContext
{
public DbSet<Table> Tables { get; set; }
}
public interface IService
{
void DoWork();
}
public class Service : IService
{
private ITenantRepositoty _tenantRepositoty;
public Service(ITenantRepositoty tenantRepositoty)
{
_tenantRepositoty = tenantRepositoty;
}
public void DoWork()
{
_tenantRepositoty.Tables.ToList();//These are filtered records
}
}
}
问题是关于EF的,不过我觉得这里值得一提的是EF Core。在 EF Core 中,您可以使用 Global Query Filters
Such filters are automatically applied to any LINQ queries involving those Entity Types, including Entity Types referenced indirectly, such as through the use of Include or direct navigation property references
一个例子:
public class Blog
{
private string _tenantId;
public int BlogId { get; set; }
public string Name { get; set; }
public string Url { get; set; }
public List<Post> Posts { get; set; }
}
public class Post
{
public int PostId { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public bool IsDeleted { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>().Property<string>("TenantId").HasField("_tenantId");
// Configure entity filters
modelBuilder.Entity<Blog>().HasQueryFilter(b => EF.Property<string>(b, "TenantId") == _tenantId);
modelBuilder.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);
}