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);
}
}

这只是一个简单的实现, 多租户系统需要关注的点还有蛮多, 比如租户的注册, 功能订阅, 计费, 数据备份, 统一管理等...

源代码

github

参考

EF Core 实现多租户的更多相关文章

  1. Asp.net core下利用EF core实现从数据实现多租户(3): 按Schema分离 附加:EF Migration 操作

    前言 前段时间写了EF core实现多租户的文章,实现了根据数据库,数据表进行多租户数据隔离. 今天开始写按照Schema分离的文章. 其实还有一种,是通过在数据表内添加一个字段做多租户的,但是这种模 ...

  2. EF core (code first) 通过自定义 Migration History 实现多租户使用同一数据库时更新数据库结构

    前言 写这篇文章的原因,其实由于我写EF core 实现多租户的时候,遇到的问题. 具体文章的链接: Asp.net core下利用EF core实现从数据实现多租户(1) Asp.net core下 ...

  3. ef core SoftDelete Multi-tenancy 软删除、多租户实现 Global Query Filters

    ef core提供了Global Query Filters特性来实现多租户与软删除,收集了一些实现方法. 最简单的例子时微软官方的特性解释. https://docs.microsoft.com/e ...

  4. Asp.net core下利用EF core实现从数据实现多租户(1)

    前言 随着互联网的的高速发展,大多数的公司由于一开始使用的传统的硬件/软件架构,导致在业务不断发展的同时,系统也逐渐地逼近传统结构的极限. 于是,系统也急需进行结构上的升级换代. 在服务端,系统的I/ ...

  5. Asp.net core下利用EF core实现从数据实现多租户(2) : 按表分离

    前言 在上一篇文章中,我们介绍了如何根据不同的租户进行数据分离,分离的办法是一个租户一个数据库. 也提到了这种模式还是相对比较重,所以本文会介绍一种更加普遍使用的办法: 按表分离租户. 这样做的好处是 ...

  6. EF core (code first) 通过自动迁移实现多租户数据分离 :按Schema分离数据

    前言 本文是多租户系列文章的附加操作文章,如果想查看系列中的其他文章请查看下列文章 主线文章 Asp.net core下利用EF core实现从数据实现多租户(1) Asp.net core下利用EF ...

  7. EF Core

    一个事务中    先在数据库查出一条数据进行修改      然后在进行查询  他会直接在内存中找到这条数据  不会再数据库查询了 EF Core的 linq语句中可以使用C#方法或函数   在EF6或 ...

  8. 文章翻译:ABP如何在EF core中添加数据过滤器

    原文地址:https://aspnetboilerplate.com/Pages/Documents/Articles%5CHow-To%5Cadd-custom-data-filter-ef-cor ...

  9. 深入理解 EF Core:使用查询过滤器实现数据软删除

    原文:https://bit.ly/2Cy3J5f 作者:Jon P Smith 翻译:王亮 声明:我翻译技术文章不是逐句翻译的,而是根据我自己的理解来表述的.其中可能会去除一些本人实在不知道如何组织 ...

随机推荐

  1. opencv学习_4(opencv基础数据结构 CvPoint & CvSize & CvRect & CvScalar & CvArr & CvMat)

    1:包含在cxcore/include/cxtypes.h头文件中. 2:CvPoint系列   -----(x,y) CvPoint:表示图像中的点 CvPoint2D32f:二维空间中的点 CvP ...

  2. 查看sql server数据库连接数的三种方法

    怎样才能查看sql server数据库连接数呢?下面就将为您介绍三种查看的方法,供您参考,希望能够帮助到您. 1.通过系统的“性能”来查看:开始->管理工具->性能(或者是运行里面输入 m ...

  3. nginx优化笔记(keepalive、https等)

    一.nginx之tcp_nopush.tcp_nodelay.sendfile 1.TCP_NODELAY你怎么可以强制 socket 在它的缓冲区里发送数据?一个解决方案是 TCP 堆栈的 TCP_ ...

  4. Java异常处理机制的秘密

    一.结论 这些结论你可能从未听说过,但其正确性是毋庸置疑的,不妨先看看: 1.catch中throw不一定能抛回到上一层,因为finally中的return会抑制这个throw 2.finally中t ...

  5. C#HttpUtility.UrlEncode 大写问题

    工作上和另一个公司对接,调对方的api需要用到md5加密,加密前要使用HttpUtility.UrlEncode,对方接口一直返回验证错误,定位了问题发现是中文编码使用HttpUtility.UrlE ...

  6. .NET Core 运行时标识符 (RID) 目录

    RID 是什么? RID 是运行时标识符的缩写. RID 用于标识其中将运行应用程序或资产(即程序集)的目标操作系统. 其外观类似如下:“ubuntu.14.04-x64”.“win7-x64”.“o ...

  7. Winform打包安装程序覆盖安装的实现

    1.修改项目程序集版本号. 2.设置Version,使当前版本号大于前一个版本号. 3.RemovePreviousVersions属性设置为true. 以上三步后,生成安装程序即可实现覆盖安装. P ...

  8. JVM内存回收区域+对象存活的判断+引用类型+垃圾回收线程

    此文已由作者赵计刚薪授权网易云社区发布. 欢迎访问网易云社区,了解更多网易技术产品运营经验. 注意:本文主要参考自<深入理解Java虚拟机(第二版)> 说明:查看本文之前,推荐先知道JVM ...

  9. 如何读取maven项目中的resources

    建立一个maven web项目,project-name/src/main下面有3个目录,java.resources.webapp java中存放java源代码,package等 resources ...

  10. With语句上下文管理

    在平时工作中总会有这样的任务,它们需要开始前做准备,然后做任务,然后收尾清理....比如读取文件,需要先打开,读取,关闭 这个时候就可以使用with简化代码,很方便 1.没有用with语句 1 2 3 ...