1. 程式人生 > >EF Core 實現多租戶

EF Core 實現多租戶

目錄

SAAS 和多租戶

SaaS(軟體及服務)區別於其他應用程式的主要特徵就是能夠使客戶在使用應用程式時按照使用量付費。他們不需要為軟體購買許可,也不需要安裝、託管和管理它。這方面的操作全部由提供 SaaS 軟體的組織負責。

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

多租戶資料隔離方案

  1. 單資料庫

    如果軟體系統僅部署一個例項,並且所有租戶的資料都是存放在一個數據庫裡面的,那麼可以通過一個 TenantId (租戶 Id) 來進行資料隔離。那麼當我們執行 SELECT 操作的時候就會附加上當前登入使用者租戶 Id 作為過濾條件,那麼查出來的資料也僅僅是當前租戶的資料,而不會查詢到其他租戶的資料。

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

    單資料庫

  2. 多資料庫

    為每一個租戶提供一個單獨的資料庫,在使用者登入的時候根據使用者對應的租戶 ID,從一個數據庫連線對映表獲取到當前租戶對應的資料庫連線字串,並且在查詢資料與寫入資料的時候,不同租戶操作的資料庫是不一樣的。

    這種方案的使用者資料隔離級別最高,安全性最好,但維護和購置成本較高.

    多資料庫

也有一種介於兩者之間的方案: 共享資料庫,獨立 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);
    }
}

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

原始碼

參考