EF Core 實現多租戶

SAAS 和多租戶

SaaS(軟件及服務)區別於其餘應用程序的主要特徵就是可以使客戶在使用應用程序時按照使用量付費。他們不須要爲軟件購買許可,也不須要安裝、託管和管理它。這方面的操做所有由提供 SaaS 軟件的組織負責。git

多租戶是實現 SaaS 的關鍵因素, 它可讓多個企業或組織用戶共用相同的系統或程序組件, 同時不會破壞這些組織的數據的安全性, 確保各組織間數據的隔離性.github

多租戶數據隔離方案

  1. 單數據庫web

    若是軟件系統僅部署一個實例,而且全部租戶的數據都是存放在一個數據庫裏面的,那麼能夠經過一個 TenantId (租戶 Id) 來進行數據隔離。那麼當咱們執行 SELECT 操做的時候就會附加上當前登陸用戶租戶 Id 做爲過濾條件,那麼查出來的數據也僅僅是當前租戶的數據,而不會查詢到其餘租戶的數據。數據庫

    這是共享程度最高、隔離級別最低的模式。須要在設計開發時加大對安全的開發量。安全

    單數據庫

  2. 多數據庫框架

    爲每個租戶提供一個單獨的數據庫,在用戶登陸的時候根據用戶對應的租戶 ID,從一個數據庫鏈接映射表獲取到當前租戶對應的數據庫鏈接字符串,而且在查詢數據與寫入數據的時候,不一樣租戶操做的數據庫是不同的。async

    這種方案的用戶數據隔離級別最高,安全性最好,但維護和購置成本較高.ide

    多數據庫

也有一種介於二者之間的方案: 共享數據庫,獨立 Schema. 但實際應用的應該很少.函數

使用 EF Core 簡單實現多租戶

租戶 Id 的獲取能夠採用兩種方法:

  • 根據登陸用戶獲取. 做爲登陸用戶的附加信息, 好比把租戶 Id 放到Json Web Token裏面或者根據用戶 Id 去數據庫裏取對應的租戶 Id.
  • 根據企業或組織用戶的Host獲取. 部署的時候會給每一個企業或組織分配一個單獨的Host, 並在數據庫裏維護着一個租戶 Id 和 Host 的映射表. 查詢的時候根據 Host 去取對應的租戶 Id.

在框架編寫的時候, 咱們最好能把對租戶 Id 的處理(查詢時候的過濾和保存時候的賦值) 放在數據訪問的最底層自動實現. 從而讓業務邏輯的開發人員儘可能少的去關注租戶 Id, 而是像開發普通應用同樣去開發多租戶應用.

EF Core 在2.0版本引入了"模型級別查詢篩選器」的新功能, 此功能能夠幫助開發人員方便實現軟刪除和多租戶等功能.

單數據庫實現

下面使用 EF Core 簡單實現一個單數據庫多租戶的 Demo. 採用 Host 獲取租戶 Id.

  1. 建立 Tenant 實體類和 TenantsContext, 用於存儲租戶 Id 和 Host 的映射, 並根據 Host 從數據庫裏獲取 Id.

    public class Tenant
    {
        public Guid Id { get; set; }
        public string Name { get; set; }
        public string Host { get; set; }
    }
    
    public class TenantConfiguration : IEntityTypeConfiguration<Tenant>
    {
        public void Configure(EntityTypeBuilder<Tenant> builder)
        {
            builder.HasKey(t => t.Id);
            builder.Property(t => t.Name).HasMaxLength(100).IsRequired();
            builder.Property(t => t.Host).HasMaxLength(100).IsRequired();
    
            builder.HasData(
                new Tenant { Id = Guid.Parse("B992D195-56CE-49BF-BFDD-4145BA9A0C13"), Name = "Customer A", Host = "localhost:5200" },
                new Tenant { Id = Guid.Parse("F55AE0C8-4573-4A0A-9EF9-32F66A828D0E"), Name = "Customer B", Host = "localhost:5300" });
        }
    }
    public class TenantsContext : DbContext
    {
        public TenantsContext(DbContextOptions<TenantsContext> options)
            : base(options)
        {
        }
    
        private DbSet<Tenant> Tenants { get; set; }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ApplyConfiguration(new TenantConfiguration());
    
            base.OnModelCreating(modelBuilder);
        }
    
        public Guid GetTenantId(string host)
        {
            var tenant = Tenants.FirstOrDefault(t => t.Host == host);
            return tenant == null ? Guid.Empty : tenant.Id;
        }
    }
  2. 建立 TenantProvider, 用於從 HttpContext 中識別 Host, 並訪問 TenantsContext 獲取 租戶 Id.

    public interface ITenantProvider
    {
        Guid GetTenantId();
    }
    
    public class TenantProvider : ITenantProvider
    {
        private Guid _tenantId;
    
        public TenantProvider(IHttpContextAccessor accessor, TenantsContext context)
        {
            var host = accessor.HttpContext.Request.Host.Value;
            _tenantId = context.GetTenantId(host);
        }
    
        public Guid GetTenantId()
        {
            return _tenantId;
        }
    }
  3. 建立 Blog 實體類和 BloggingContext. 有幾個注意點

    • BaseEntity 類裏面包含 TenantId, 因此須要共享數據的表都要繼承自這個基類.
    • BloggingContext 的構造函數裏面加入參數 ITenantProvider tenantProvider, 用於獲取租戶 Id.
    • 在 OnModelCreating 方法裏面對全部繼承於 BaseEntity 的實體類配置全局過濾 builder.Entity<T>().HasQueryFilter(e => e.TenantId == _tenantId).
    • 重載 SaveChangesAsync 等方法, 保存數據的時候自動賦值 TenantId.
public abstract class BaseEntity
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
}
public class Blog : BaseEntity
{
    public string Name { get; set; }
    public string Url { get; set; }

    public virtual IList<Post> Posts { get; set; }
}

public class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
    public void Configure(EntityTypeBuilder<Blog> builder)
    {
        builder.HasKey(t => t.Id);
        builder.Property(t => t.Name).HasMaxLength(100).IsRequired();
        builder.Property(t => t.Url).HasMaxLength(100).IsRequired();

        builder.HasData(
            new Blog { Id = 1, Name = "Blog1 by A", Url = "http://sample.com/1", TenantId= Guid.Parse("B992D195-56CE-49BF-BFDD-4145BA9A0C13") },
            new Blog { Id = 2, Name = "Blog2 by A", Url = "http://sample.com/2", TenantId = Guid.Parse("B992D195-56CE-49BF-BFDD-4145BA9A0C13") },
            new Blog { Id = 3, Name = "Blog1 by B", Url = "http://sample.com/3", TenantId = Guid.Parse("F55AE0C8-4573-4A0A-9EF9-32F66A828D0E") });
    }
}
public class BloggingContext : DbContext
{
    private Guid _tenantId;

    public BloggingContext(DbContextOptions<BloggingContext> options, ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantId = tenantProvider.GetTenantId();
    }

    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new BlogConfiguration());
        modelBuilder.ApplyConfiguration(new PostConfiguration());

        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (entityType.ClrType.BaseType == typeof(BaseEntity))
            {
                ConfigureGlobalFiltersMethodInfo
                    .MakeGenericMethod(entityType.ClrType)
                    .Invoke(this, new object[] { modelBuilder });
            }
        }

        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
    {
        ChangeTracker.DetectChanges();

        var entities = ChangeTracker.Entries().Where(e => e.State == EntityState.Added && e.Entity.GetType().BaseType == typeof(BaseEntity));
        foreach (var item in entities)
        {
            (item.Entity as BaseEntity).TenantId = _tenantId;
        }

        return await base.SaveChangesAsync(cancellationToken);
    }

    #region

    private static MethodInfo ConfigureGlobalFiltersMethodInfo = typeof(BloggingContext).GetMethod(nameof(ConfigureGlobalFilters), BindingFlags.Instance | BindingFlags.NonPublic);

    protected void ConfigureGlobalFilters<T>(ModelBuilder builder) where T : BaseEntity
    {
        builder.Entity<T>().HasQueryFilter(e => e.TenantId == _tenantId);
    }

    #endregion
}
  1. 在 Startup 裏面配置依賴注入

    services.AddDbContext<TenantsContext>(option => option.UseSqlServer(connectionString));
    services.AddDbContext<BloggingContext>(option => option.UseSqlServer(connectionString));
    services.AddScoped<ITenantProvider, TenantProvider>();
    services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

多數據庫實現

多數據的實現也不復雜, 在 Tenant 實體類裏面加入新的字段 DatabaseConnectionString 用於存放每一個租戶的數據庫鏈接字符串, 在 BloggingContext 的 OnConfiguring 方法裏面根據獲取的 Tenant 配置鏈接字符串.

public class Tenant
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Host { get; set; }
    public string DatabaseConnectionString { get; set; }
}
public class BloggingContext : DbContext
{
    private readonly Tenant _tenant;
 
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
 
    public BloggingContext(DbContextOptions<BloggingContext> options,
                            ITenantProvider tenantProvider)
        : base(options)
    {
        _tenant = tenantProvider.GetTenant();
    }
 
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(_tenant.DatabaseConnectionString);
 
        base.OnConfiguring(optionsBuilder);
    }
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfiguration(new BlogConfiguration());
        modelBuilder.ApplyConfiguration(new PostConfiguration());
 
        base.OnModelCreating(modelBuilder);
    }
}

這只是一個簡單的實現, 多租戶系統須要關注的點還有蠻多, 好比租戶的註冊, 功能訂閱, 計費, 數據備份, 統一管理等...

源代碼

github

參考

相關文章
相關標籤/搜索