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);
}
}
这只是一个简单的实现, 多租户系统需要关注的点还有蛮多, 比如租户的注册, 功能订阅, 计费, 数据备份, 统一管理等...