17. 多租户SAAS
提示
Admin.NET 默认启用多租户模式,默认的主库其实就是一个默认租户。若不涉及租户数据管理,业务应用层可以忽略,完全不用理会这个租户及其逻辑处理。常见的多租户 SAAS 分库一般分两种情况,租户 Id 隔离和独立数据库隔离。Admin.NET 已经集成此两种模式,可以任意选择使用。
Admin.NET 多租户数据库设计
1、基础信息库
主要存组织架构 、权限、字典、用户等 公共信息
性能优化:因为基础信息库是共享的,所以我们可以使用 读写分离,或者二级缓存来进行性能上的优化
2、应用业务库
我们要进行的分库都基于业务库进行分库,例如:A 集团使用 A01 库,B 集团使用 B01 库,也可以多个小集团使用一个数据库,如下:
业务库1:集团A、VIP用户独享一个库
业务库2:集团B、 集团F
业务库3:集团C、集团D、集团E...... 集团Z,小客户多人共享一个库
性能无瓶颈可扩展:因为合理的进行了分库,所以在性能上并没有什么瓶颈,并且数据库可以扔到不同的服务器上
提示
Admin.NET 针对多租户数据操作时,都做了封装操作,比如切换指定租户库时
                            var db = iTenant.GetConnectionScope(config.ConfigId.ToString()); 直接根据租户Id切换即可。
                        
推荐使用此方法进行租户库切换
                            App.GetRequiredService<SysTenantService>().GetTenantDbConnectionScope(long.Parse(tenantId));
                            , 类 SysTenantService 对租户列表有缓存处理,速度更快。
针对使用仓储操作数据库时,直接注入相应的业务实体仓储即可,和普通数据库仓储没嘛区别,框架针对租户仓储进行了封装处理,自动进行租户库切换,业务应用层无感。
namespace Admin.NET.Core;
/// <summary>
/// SqlSugar 实体仓储
/// </summary>
/// <typeparam name="T"></typeparam>
public class SqlSugarRepository<T> : SimpleClient<T>, ISqlSugarRepository<T> where T : class, new()
{
    public SqlSugarRepository()
    {
        var iTenant = SqlSugarSetup.ITenant; // App.GetRequiredService<ISqlSugarClient>().AsTenant();
        base.Context = iTenant.GetConnectionScope(SqlSugarConst.MainConfigId);
        // 若实体贴有多库特性,则返回指定库连接
        if (typeof(T).IsDefined(typeof(TenantAttribute), false))
        {
            base.Context = iTenant.GetConnectionScopeWithAttr<T>();
            return;
        }
        // 若实体贴有日志表特性,则返回日志库连接
        if (typeof(T).IsDefined(typeof(LogTableAttribute), false))
        {
            if (iTenant.IsAnyConnection(SqlSugarConst.LogConfigId))
                base.Context = iTenant.GetConnectionScope(SqlSugarConst.LogConfigId);
            return;
        }
        // 若实体贴有系统表特性,则返回默认库连接
        if (typeof(T).IsDefined(typeof(SysTableAttribute), false))
            return;
        // 若未贴任何表特性或当前未登录或是默认租户Id,则返回默认库连接
        var tenantId = App.User?.FindFirst(ClaimConst.TenantId)?.Value;
        if (string.IsNullOrWhiteSpace(tenantId) || tenantId == SqlSugarConst.MainConfigId) return;
        // 根据租户Id切换库连接, 为空则返回默认库连接
        var sqlSugarScopeProviderTenant = App.GetRequiredService<SysTenantService>().GetTenantDbConnectionScope(long.Parse(tenantId));
        if (sqlSugarScopeProviderTenant == null) return;
        base.Context = sqlSugarScopeProviderTenant;
    }
}在菜单【平台管理】-【租户管理】页面创建租户,以租户 Id 和数据库隔离两种模式自行选择。
 
                        租户 Id 数据隔离模式就一个当前默认数据库,各租户数据都已租户 Id 进行过滤隔离。
数据库隔离模式支持常见的数据库种类,注意一定要输入正确的数据库连接字符串。创建租户库的时候,会自动生成一个租户管理员,给租户管理员分配菜单和接口资源权限,然后该租户管理员给自己租户内可以任意创建自己的租户账号、租户组织结构、租户角色、租户职位等等,这样各个租户权限和数据都是库隔离的。注意所有的租户账号都不能相同,因为租户账号都存在了主库账号表里面。因为账号表本身具有租户 Id 字段,理论上可以实现各租户账号允许相同的存在,但是这样一样登录的时候得指定租户或者以二级域名来区分了,可自行修改。
                    
下面时创建租户数据库逻辑代码,租户数据库里面只有业务应用实体表,没有账号、权限等平台数据表。
    /// <summary>
    /// 初始化租户业务数据库
    /// </summary>
    /// <param name="iTenant"></param>
    /// <param name="config"></param>
    public static void InitTenantDatabase(ITenant iTenant, DbConnectionConfig config)
    {
        SetDbConfig(config);
        if (!iTenant.IsAnyConnection(config.ConfigId.ToString()))
            iTenant.AddConnection(config);
        var db = iTenant.GetConnectionScope(config.ConfigId.ToString());
        db.DbMaintenance.CreateDatabase();
        // 初始化租户库表结构-获取所有业务应用表(排除系统表、日志表、特定库表)
        var entityTypes = App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass && u.IsDefined(typeof(SugarTable), false) &&
            !u.IsDefined(typeof(SysTableAttribute), false) && !u.IsDefined(typeof(LogTableAttribute), false) && !u.IsDefined(typeof(TenantAttribute), false)).ToList();
        if (!entityTypes.Any()) return;
        foreach (var entityType in entityTypes)
        {
            var splitTable = entityType.GetCustomAttribute<SplitTableAttribute>();
            if (splitTable == null)
                db.CodeFirst.InitTables(entityType);
            else
                db.CodeFirst.SplitTables().InitTables(entityType);
        }
        // 初始化业务应用种子数据
        var seedDataTypes = App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass && u.GetInterfaces().Any(i => i.HasImplementedRawGeneric(typeof(ISqlSugarEntitySeedData<>))))
            .Where(u => u.IsDefined(typeof(AppSeedAttribute), false)).ToList();
        foreach (var seedType in seedDataTypes)
        {
            var instance = Activator.CreateInstance(seedType);
            var hasDataMethod = seedType.GetMethod("HasData");
            var seedData = ((IEnumerable)hasDataMethod?.Invoke(instance, null))?.Cast<object>().ToList();
            if (seedData == null) continue;
            var entityType = seedType.GetInterfaces().First().GetGenericArguments().First();
            var entityInfo = db.EntityMaintenance.GetEntityInfo(entityType);
            var dbConfigId = config.ConfigId.ToLong();
            // 若实体包含租户Id字段,则设置为当前租户Id
            if (entityInfo.Columns.Any(u => u.PropertyName == nameof(EntityTenantId.TenantId)))
            {
                foreach (var sd in seedData)
                {
                    sd.GetType().GetProperty(nameof(EntityTenantId.TenantId)).SetValue(sd, dbConfigId);
                }
            }
            // 若实体包含Pid字段,则设置为当前租户Id
            if (entityInfo.Columns.Any(u => u.PropertyName == nameof(SysOrg.Pid)))
            {
                foreach (var sd in seedData)
                {
                    sd.GetType().GetProperty(nameof(SysOrg.Pid)).SetValue(sd, dbConfigId);
                }
            }
            // 若实体包含Id字段,则设置为当前租户Id递增1
            if (entityInfo.Columns.Any(u => u.PropertyName == nameof(EntityBaseId.Id)))
            {
                foreach (var sd in seedData)
                {
                    sd.GetType().GetProperty(nameof(EntityBaseId.Id)).SetValue(sd, ++dbConfigId);
                }
            }
            // 若实体是系统内置,则切换至默认库
            if (entityType.GetCustomAttribute<SysTableAttribute>() != null)
                db = iTenant.GetConnectionScope(SqlSugarConst.MainConfigId);
            if (entityInfo.Columns.Any(u => u.IsPrimarykey))
            {
                // 按主键进行批量增加和更新
                var storage = db.StorageableByObject(seedData).ToStorage();
                storage.AsInsertable.ExecuteCommand();
                if (seedType.GetCustomAttribute<IgnoreUpdateSeedAttribute>() == null) // 有忽略更新种子特性时则不更新
                    storage.AsUpdateable.IgnoreColumns(entityInfo.Columns.Where(c => c.PropertyInfo.GetCustomAttribute<IgnoreUpdateSeedColumnAttribute>() != null).Select(c => c.PropertyName).ToArray()).ExecuteCommand();
            }
            else
            {
                // 无主键则只进行插入
                if (!db.Queryable(entityInfo.DbTableName, entityInfo.DbTableName).Any())
                    db.InsertableByObject(seedData).ExecuteCommand();
            }
        }
    }