SAAS 和多租戶
SaaS(軟件及服務)區別於其他應用程序的主要特征就是能夠使客戶在使用應用程序時按照使用量付費。他們不需要為軟件購買許可,也不需要安裝、托管和管理它。這方面的操作全部由提供 SaaS 軟件的組織負責。
多租戶是實現 SaaS 的關鍵因素, 它可以讓多個企業或組織用戶共用相同的系統或程序組件, 同時不會破壞這些組織的數據的安全性, 確保各組織間數據的隔離性.
多租戶數據隔離方案
-
單數據庫
如果軟件系統僅部署一個實例,並且所有租戶的數據都是存放在一個數據庫里面的,那么可以通過一個 TenantId (租戶 Id) 來進行數據隔離。那么當我們執行 SELECT 操作的時候就會附加上當前登錄用戶租戶 Id 作為過濾條件,那么查出來的數據也僅僅是當前租戶的數據,而不會查詢到其他租戶的數據。
這是共享程度最高、隔離級別最低的模式。需要在設計開發時加大對安全的開發量。

-
多數據庫
為每一個租戶提供一個單獨的數據庫,在用戶登錄的時候根據用戶對應的租戶 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.
-
創建 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; } } -
創建 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; } } -
創建 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
}
-
在 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);
}
}
這只是一個簡單的實現, 多租戶系統需要關注的點還有蠻多, 比如租戶的注冊, 功能訂閱, 計費, 數據備份, 統一管理等...
