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

参考

相关文章
相关标签/搜索