DbContext和TenantId的多租户 - 拦截器、过滤器、EF Code-First

18

我的组织需要一个共享数据库,共享架构的多租户数据库。我们将基于TenantId进行查询。我们将有很少的租户(不到10个),并且所有租户将共享相同的数据库架构,没有支持租户特定更改或功能的支持。租户元数据将存储在内存中,而不是数据库中(静态成员)。

这意味着现在所有实体都需要一个TenantId,并且DbContext需要默认知道如何过滤它。

TenantId可能会通过标头值或来源域来识别,除非有更可取的方法。

我已经看到了各种利用拦截器的示例,但还没有看到关于TenantId实现的清晰范例。


我们需要解决的问题:

  1. 如何修改当前模式以支持此功能(我认为很简单,只需添加TenantId)
  2. 如何检测租户(也很简单 - 基于发出请求的域或标头值 - 从BaseController中获取)
  3. 如何将其传播到服务方法(有点棘手...我们使用DI通过构造函数进行水合作用...希望避免在所有方法签名中加入tenantId)
  4. 如何修改DbContext以过滤此tenantId(没想法)
  5. 如何针对性能进行优化。我们需要哪些索引,如何确保查询缓存不会在租户Id隔离上做任何奇怪的事情等(没想法)
  6. 身份验证 - 使用SimpleMembership,如何隔离用户,以某种方式将它们与租户关联。

我认为最大的问题是第4个 - 修改DbContext。


我喜欢这篇文章如何利用RLS,但我不确定如何在代码优先,dbContext的方式处理这个问题:

https://azure.microsoft.com/en-us/documentation/articles/web-sites-dotnet-entity-framework-row-level-security/

我认为我正在寻找一种方法 - 考虑性能因素 - 通过使用DbContext有选择地查询TenantId隔离的资源,而不用在所有调用中添加"AND TenantId = 1"之类的内容。


更新 - 我找到了一些选项,但是我不确定每个选项的优缺点,或者是否有更好的方法可供选择。我的选项评估如下:

  • 实现的容易程度
  • 性能

方案A

这似乎是“昂贵”的,因为每次我们新建一个dbContext时,我们都必须重新初始化过滤器:

https://blogs.msdn.microsoft.com/mvpawardprogram/2016/02/09/row-level-security-in-entityframework-6-ef6/

首先,我设置了我的租户和接口:

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。

我想在现在这个时间点,一定会有成熟、可行的配置/架构来适应这个需求。我们应该怎样做呢?


我更喜欢按租户的架构方式。您可以使用模式名称实例化上下文。 - Gert Arnold
@GertArnold,我的业务需求是利用一个数据库为多个租户提供服务 - 我只是假设TenantId是最简单的方法。您有没有推荐的阅读材料或示例来解决我上述提出的问题(1-5)? - SB2055
请查看共享数据库,分离模式:https://msdn.microsoft.com/zh-cn/library/aa479086.aspx - Gert Arnold
@GertArnold 经过审核,我们将坚持使用 tenantId 方法,所以我会保留这个。谢谢。 - SB2055
是的,为了减少代码更改,您可以将DbSet YourEntities更改为IQueryable YourEntities(由TenantID过滤),然后添加DbSet NonTenantEntities(最好找另一个名称:))。原因是现有代码库中,您的查询现在会自动通过租户ID进行“过滤”,而您的修改语句(Add,Remove)现在会产生编译错误(IQueryable不包含这些方法),这是好的(易于查找和修复)。在您完成此操作后,最好将实体重命名并应用重命名重构。 - Evk
显示剩余6条评论
3个回答

5
我建议以下方法:
1. 为包含核心业务数据的每个表格创建一个名为“租户ID”的列,这对于任何映射表都不需要。
2. 使用方法B,创建一个返回IQueryable的扩展方法。该方法可以是dbset的扩展方法,这样编写筛选子句的人只需调用此扩展方法,然后跟随谓词即可。这将使开发人员更容易编写代码,而无需担心租户ID过滤器。此特定方法将具有根据执行此查询的租户上下文应用租户ID列的过滤条件的代码。
示例: ctx.TenantFilter().Where(....)
3. 在所有服务方法中都传递租户ID,而不是依赖于http上下文,以便轻松处理Web和Web作业应用程序中的租户联系人。这使得调用无需联系人并更易于测试。多租户实体接口方法看起来不错,我们的应用程序中也有类似的限制,目前还很好。
4. 关于添加索引,您需要在具有租户ID的表格中添加租户ID列的索引,这应该能解决DB端的查询索引部分。
5. 关于身份验证部分,我建议使用带有owin管道的asp.net identity 2.0。该系统非常可扩展,可自定义,并且易于与任何外部身份提供者集成,如果将来需要的话。
6. 请查看Entity Framework的存储库模式, 这使您可以以通用方式编写较少的代码。这将帮助我们摆脱代码重复和冗余,并且非常容易从单元测试案例进行测试。

谢谢您。我唯一的担忧是3 - 这将对我们的服务层造成严重破坏。 - SB2055
1
在这种情况下,您可以从用户上下文中获取租户ID。例如,我们有一个用户身份上下文,在用户登录时设置,然后在服务层中使用它。这种方法可以避免服务层中的破坏或更多变化。 - Saravanan
嗨,我该如何将此添加到EF的“Add Save Remove Update”函数中? - Transformer
请查看 https://github.com/d-saravanan/multitenant.dev.template 上的模板代码。这可以为您提供良好的起点。 - Saravanan
@Saravanan 不错的示例,您能否添加一个片段来展示如何支持TenantId、上下文过滤表?例如,当用户登录时是否设置了tenantId并在注销时释放。并且是否可以确保只显示由其tenantId过滤的记录 - 假设在AspUsersTable中添加了tenantId,并且其他表具有Int TenantId。我还看到您使用了claims。您能否告诉我们,claims与添加属性有何不同。 - aggie
@aggie:我已经在源代码中添加了一些文档和解释。希望对你有所帮助。 - Saravanan

5
这个问题涉及 EF,但我认为在这里值得提到 EF Core。在 EF Core 中,您可以使用全局查询过滤器

此类过滤器会自动应用于任何涉及这些实体类型的 LINQ 查询,包括间接引用的实体类型,例如通过使用 Include 或直接导航属性引用

示例:

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);
}

这很酷。对于多租户和“软删除”过滤的示例,都非常有用。 - urza.cc
3
不过有一个问题:如何获取tenantId以便在DbContext中可用。tenantId是从请求中确定的(cookie、已登录用户等),但在DbContext中不可用。 - urza.cc
@urza.cc 我建议你为此创建一个单独的问题。这不应该很难,因为您完全控制dbcontext对象以及它是如何注入的(以及tenantId值是如何设置的)。 - julealgon

4

我认为最大的问题是修改DbContext。

不要修改上下文...

您不应该将租户过滤代码与业务代码混合使用。

我认为您只需要一个仓库返回已过滤的数据
该仓库将基于从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的“Add Save Remove Update”函数中? - Transformer
1
@transformer 对于删除/更新,我认为最安全的选择是先加载实体(您可以在已过滤的IQueryable上使用Find())。如果有任何租户ID,Save可能会覆盖它们。 - George Vovos
1
@transformer 我更新了 ITenantRepositoty 和 TenantRepository。 - George Vovos
嗨,更新对我的使用有所帮助,谢谢!一些有用的建议:1)将通用实体类型的表参数化,这样我就可以作为任何表的扩展进行调用。public class TenantContext : DbContext { public DbSet<T> Tables { get; set; } // 是否可能使其通用,以便与上下文中的任何/所有表一起使用? } 2)上下文在哪里设置或传递? - aggie
@aggie 1) 将dbset泛型化没有意义,因为你将无法使用它...2)你可以像我的示例一样在存储库中自己创建它,或者你可以引入另一个依赖项到存储库中,例如ICOntextProvider,它有一个返回新上下文的方法。 - George Vovos
显示剩余8条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接