Skip to main content

11. SaaS 多租户

11.1 什么是 SaaS

SaaS 是 Software-as-a-Service(软件即服务)的简称,随着互联网技术的发展和应用软件的成熟, 在 21 世纪开始兴起的一种完全创新的软件应用模式。它与“on-demand software”,the application service provider(ASP,应用服务提供商),hosted software(托管软件)所具有相似的含义。

它是一种通过 Internet 提供软件的模式,厂商将应用软件统一部署在自己的服务器上,客户可以根据自己实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得厂商提供的服务。用户不用再购买软件,而改用向提供商租用基于 Web 的软件,来管理企业经营活动,且无需对软件进行维护,服务提供商会全权管理和维护软件,软件厂商在向客户提供互联网应用的同时,也提供软件的离线操作和本地数据存储,让用户随时随地都可以使用其定购的软件和服务。

对于许多小型企业来说,SaaS 是采用先进技术的最好途径,它消除了企业购买、构建和维护基础设施和应用程序的需要。

11.2 什么是多租户

多租户技术或称多重租赁技术,简称 SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。

简单讲:在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。那么重点就很浅显易懂了,多租户的重点就是同一套程序下实现多用户数据的隔离。

11.3 实现多租户方案

11.3.1 独立数据库(基于 Database 的方式)

这是第一种方案,即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。

  • 优点: 为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。

  • 缺点: 增多了数据库的安装数量,随之带来维护成本和购置成本的增加。 这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。

11.3.2 共享数据库,独立 Schema (基于 Schema 的方式)

这是第二种方案,即多个或所有租户共享 Database,但是每个租户一个 Schema(也可叫做一个 user)。底层库比如是:SqlServerOracle 等,一个数据库下可以有多个 Schema

  • 优点: 为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。

  • 缺点: 如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据; 如果需要跨租户统计数据,存在一定困难。

11.3.3 共享数据库,共享 Schema (基于 TenantId 的方式)

共享数据表 这是第三种方案,即租户共享同一个 Database、同一个 Schema,但在表中增加 TenantId 多租户的数据字段。这是共享程度最高、隔离级别最低的模式。 即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据。

  • 优点: 三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

  • 缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。

11.4 多租户使用方案

Furion 框架支持以上三种多租户实现方案,使用简单且容易维护。下面分别使用三种不同方式演示多租户方案用法。

特别说明

一旦 数据库上下文 类继承了租户任意接口,则自动开始多租户功能支持。

11.5 基于 TenantId 的方式

此方式在中小型企业系统中最为常用,维护成本低,购置成本低。

11.5.1 创建租户数据库上下文

Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
{
public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
{
}
}
}
特别注意

多租户操作建议单独一个数据库上下文,而且需指定 MultiTenantDbContextLocator 数据库上下文定位器。

11.5.2 注册多租户数据库上下文

using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
[AppStartup(600)]
public sealed class FurEntityFrameworkCoreStartup : AppStartup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDatabaseAccessor(options =>
{
options.AddDbPool<FurionDbContext>();
options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>();
});
}
}
}

11.5.3 添加 Tenant 种子数据

Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
{
public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
{
return new List<Tenant>
{
new Tenant
{
TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
Name = "默认租户",
Host = "localhost:44313",
CreatedTime = DateTime.Parse("2020-10-06 20:19:07")
},
new Tenant
{
TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
Name = "其他租户",
Host = "localhost:5000",
CreatedTime = DateTime.Parse("2020-10-06 20:20:32")
}
};
}
}
}
特别说明

该步骤只在 Code First 方式执行,Database First 无需配置种子数据。

11.5.4 根据模型创建 Tenant

Add-Migration add_tenant_table -Context MultiTenantDbContext
Update-Database -Context MultiTenantDbContext

11.5.5 实现 IMultiTenantOnTable 接口

在需要多租户的数据库上下文中实现 IMultiTenantOnTable 接口,如:

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

public object GetTenantId()
{
return base.Tenant?.TenantId ?? Guid.Empty;
}
}
}

GetTenantId() 方法中,首先获取请求的 主机地址,然后根据主机地址查询对应的租户 TenantId,避免多次查询数据库,这里使用了 IMemoryCache 内存缓存。

特别说明

base.Tenant 只是 Furion 框架提供的默认租户实现方法,如果不能满足业务需求,只需要在 GetTenantId 里面写你的业务代码即可,也就是无需调用 base.Tenant。如:

public object GetTenantId()
{
// 这里是你获取 TenantId 的逻辑
return 你的 TenantId;
}

11.5.6 实现 IModelBuilderFilter 接口

IModelBuilderFilter 接口是全局查询过滤器实现接口,所以我们需要配置实体 TenantId 过滤器

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

public object GetTenantId()
{
return base.Tenant?.TenantId ?? Guid.Empty;
}

public void OnCreating(ModelBuilder modelBuilder, EntityTypeBuilder entityBuilder, DbContext dbContext, Type dbContextLocator)
{
entityBuilder.HasQueryFilter(BuildTenantQueryFilter(entityBuilder, dbContext));
}
}
}

11.5.7 重写 SavingChangesEvent 事件方法

通过上面的步骤,我们已经解决了 查询 租户过滤功能,但是 新增更新 还未处理。

  • 新增 数据的时候自动设置 TenantId 的值
  • 更新 数据的时候排除 TenantId 属性更新

实现上面的步骤很简单,只需要重写 SavingChangesEvent 事件方法即可。

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
using System.Linq;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnTable, IModelBuilderFilter
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

public object GetTenantId()
{
return base.Tenant?.TenantId ?? Guid.Empty;
}

public void OnCreating(ModelBuilder modelBuilder, EntityTypeBuilder entityBuilder, DbContext dbContext, Type dbContextLocator)
{
entityBuilder.HasQueryFilter(BuildTenantQueryFilter(entityBuilder, dbContext));
}

protected override void SavingChangesEvent(DbContextEventData eventData, InterceptionResult<int> result)
{
// 获取当前事件对应上下文
var dbContext = eventData.Context;

// 获取所有新增、更新、删除的实体
var entities = dbContext.ChangeTracker.Entries().Where(u => u.State == EntityState.Added || u.State == EntityState.Modified || u.State == EntityState.Deleted);

foreach (var entity in entities)
{
switch (entity.State)
{
// 自动设置租户Id
case EntityState.Added:
entity.Property(nameof(Entity.TenantId)).CurrentValue = GetTenantId();
break;
// 排除租户Id
case EntityState.Modified:
entity.Property(nameof(Entity.TenantId)).IsModified = false;
break;
// 删除处理
case EntityState.Deleted:
break;
}
}
}
}
}

11.6 基于 Database 的方式

此方式在中大型企业系统中最为常用,一个租户(客户)一个独立的数据库。

11.6.1 创建租户数据库上下文

Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
{
public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
{
}
}
}
特别注意

多租户操作建议单独一个数据库上下文,而且需指定 MultiTenantDbContextLocator 数据库上下文定位器。

11.6.2 注册多租户数据库上下文

using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
[AppStartup(600)]
public sealed class FurEntityFrameworkCoreStartup : AppStartup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDatabaseAccessor(options =>
{
options.AddDb<FurionDbContext>();
options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>();
});
}
}
}
特别注意

需要 Database 多租户方案的数据库上下文需要采用 AddDb 注册,而不是 AddDbPool。原因是 AddDbPool 方式注册后续不支持 OnConfiguring 重写!!!

11.6.3 添加 Tenant 种子数据

Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
{
public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
{
return new List<Tenant>
{
new Tenant
{
TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
Name = "默认租户",
Host = "localhost:44313",
CreatedTime = DateTime.Parse("2020-10-06 20:19:07"),
ConnectionString = "Data Source=./Furion.db" // 配置连接字符串
},
new Tenant
{
TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
Name = "其他租户",
Host = "localhost:5000",
CreatedTime = DateTime.Parse("2020-10-06 20:20:32"),
ConnectionString = "Data Source=./Fur2.db" // 配置连接字符串
}
};
}
}
}
特别说明

该步骤只在 Code First 方式执行,Database First 无需配置种子数据。

11.6.4 根据模型创建 Tenant

Add-Migration add_tenant_table -Context MultiTenantDbContext
Update-Database -Context MultiTenantDbContext

11.6.5 实现 IMultiTenantOnDatabase 接口

在需要多租户的数据库上下文中实现 IMultiTenantOnDatabase 接口,如:

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
// 这里可不配置
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnDatabase
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

public string GetDatabaseConnectionString()
{
return base.Tenant?.ConnectionString??"默认链接字符串";
}
}
}
特别说明

base.Tenant 只是 Furion 框架提供的默认租户实现方法,如果不能满足业务需求,只需要在 GetDatabaseConnectionString 里面写你的业务代码即可,也就是无需调用 base.Tenant。如:

public string GetDatabaseConnectionString()
{
// 这里是你获取 DatabaseConnecionString 的逻辑
return 你的 连接字符串;
}

11.6.6 重写 OnConfiguring 方法

在需要多租户的数据库上下文中重写 OnConfiguring 方法并配置连接字符串:

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnDatabase
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite(GetDatabaseConnectionString());

base.OnConfiguring(optionsBuilder);
}

public string GetDatabaseConnectionString()
{
return base.Tenant?.ConnectionString??"默认链接字符串";
}
}
}
特别注意

基于 Database 方式做 Code First 的时候,需要手动指定迁移程序名称,如:

optionsBuilder.UseSqlite(GetDatabaseConnectionString(), options=>
{
options.MigrationsAssembly("My.Migrations");
});

11.7 基于 Schema 的方式

此方式在中小型企业系统中也不少见,一个租户(客户)共享数据库且不同 Schema

11.7.1 创建租户数据库上下文

Furion.EntityFramework.Core\DbContexts\MultiTenantDbContext.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class MultiTenantDbContext : AppDbContext<MultiTenantDbContext, MultiTenantDbContextLocator>
{
public MultiTenantDbContext(DbContextOptions<MultiTenantDbContext> options) : base(options)
{
}
}
}
特别注意

多租户操作建议单独一个数据库上下文,而且需指定 MultiTenantDbContextLocator 数据库上下文定位器。

11.7.2 注册多租户数据库上下文

using Furion.DatabaseAccessor;
using Microsoft.Extensions.DependencyInjection;

namespace Furion.EntityFramework.Core
{
[AppStartup(600)]
public sealed class FurEntityFrameworkCoreStartup : AppStartup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddDatabaseAccessor(options =>
{
options.AddDbPool<FurionDbContext>();
options.AddDbPool<MultiTenantDbContext, MultiTenantDbContextLocator>();
});
}
}
}

11.7.3 添加 Tenant 种子数据

Furion.EntityFramework.Core\SeedDatas\TenantSeedData.cs
using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;

namespace Furion.EntityFramework.Core
{
public class TenantSeedData : IEntitySeedData<Tenant, MultiTenantDbContextLocator>
{
public IEnumerable<Tenant> HasData(DbContext dbContext, Type dbContextLocator)
{
return new List<Tenant>
{
new Tenant
{
TenantId = Guid.Parse("383AFB88-F519-FFF8-B364-6D563BF3687F"),
Name = "默认租户",
Host = "localhost:44313",
CreatedTime = DateTime.Parse("2020-10-06 20:19:07"),
Schema = "dbo" // Schema
},
new Tenant
{
TenantId = Guid.Parse("C5798CB6-16D6-0F42-EB56-59C695353BC0"),
Name = "其他租户",
Host = "localhost:5000",
CreatedTime = DateTime.Parse("2020-10-06 20:20:32"),
Schema = "furion" // Schema
}
};
}
}
}
特别说明

该步骤只在 Code First 方式执行,Database First 无需配置种子数据。

11.7.4 根据模型创建 Tenant

Add-Migration add_tenant_table -Context MultiTenantDbContext
Update-Database -Context MultiTenantDbContext

11.7.5 实现 IMultiTenantOnSchema 接口

在需要多租户的数据库上下文中实现 IMultiTenantOnSchema 接口,如:

using Furion.DatabaseAccessor;
using Microsoft.EntityFrameworkCore;

namespace Furion.EntityFramework.Core
{
[AppDbContext("Sqlite3ConnectionString", DbProvider.Sqlite)]
public class FurionDbContext : AppDbContext<FurionDbContext>, IMultiTenantOnSchema
{
public FurionDbContext(DbContextOptions<FurionDbContext> options) : base(options)
{
}

public string GetSchemaName()
{
return base.Tenant?.Schema??"dbo";
}
}
}
特别说明

base.Tenant 只是 Furion 框架提供的默认租户实现方法,如果不能满足业务需求,只需要在 GetSchemaName 里面写你的业务代码即可,也就是无需调用 base.Tenant。如:

public string GetSchemaName()
{
// 这里是你获取 Schema 的逻辑
return 你的 Schema;
}

11.7.6 关于 Code First 数据迁移

基于 Schema 方式比较特别,生成数据迁移的时候没办法获取租户信息,所以建议分开多次迁移,如:

public string GetSchemaName()
{
return base.Tenant?.Schema?? "租户一Schema";
}
public string GetSchemaName()
{
return base.Tenant?.Schema?? "租户二Schema";
}

这样就可以在迁移的时候生成多次迁移了。

11.8 自定义 Tenant 类型

默认情况下,Furion 框架提供了内置的 Tenant 类型,方便大家快速实现 SaaS 多租户功能,如果需要自定义多租户 Tenant 类型,只需要启用以下配置即可:

11.8.1 启动自定义多租户类型配置

services.AddDatabaseAccessor(options =>
{
options.CustomizeMultiTenants(); // 启用自定义多租户类型,有一个默认参数,配置多租户表字段名
options.AddDbPool<FurionDbContext>();
});

11.8.2 自定义租户类

using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace Furion.Core
{
public class MyTenant : IEntity<MultiTenantDbContextLocator>
{
[Key]
public Guid TenantId { get; set; }

public string Name { get; set; }

public string Host { get; set; }
}
}

如果需要查询该租户信息,可通过以下代码获取,如:

var tenantDbContext = Db.GetDbContext<MultiTenantDbContextLocator>();
var myTenant = tenantDbContext.Set<MyTenant>();

11.9 刷新租户缓存

Furion 框架会在租户上下文第一次查询时候将租户表缓存起来,避免频发查询数据库,如果更新了租户表,则需要手动刷新租户信息,如:

using Furion.DatabaseAccessor.Extensions;

// 在更新租户信息后调用
_repository.Context.RefreshTenantCache();

11.10 反馈与建议

与我们交流

给 Furion 提 Issue