[Abp vNext source code analysis] - 19. Multi-tenant

I. Introduction

ABP vNext native support for multi-tenant architecture that allows developers to quickly develop SaaS-based framework system. ABP vNext multi-tenant idea is very simple, by a TenantIdsegmenting data for each tenant, and use the same global filter (at the time of the query is similar to soft delete ) to filter the data.

Something about multi-tenant system, basic definitions and core logic stored in Volo.ABP.MultiTenancy inside. For ASP.NET Core MVC integration is a Volo.ABP.AspNetCore.MultiTenancy realization of the project, both for internal multi-tenant resolved in this project. Tenant data storage and management by Volo.ABP.TenantManagement module, developers can directly use this project to quickly implement multi-tenant capabilities.

Second, source code analysis

2.1 Start Module

AbpMultiTenancyModuleModule is enabled core module of the multi-tenant capabilities, internal only an action, is to read the basic information from the multi-tenant configuration class which, to JSON Provider, for example, you need the appsettings.jsoninside Tenantssection.

"Tenants": [
    {
      "Id": "446a5211-3d72-4339-9adc-845151f8ada0",
      "Name": "tenant1"
    },
    {
      "Id": "25388015-ef1c-4355-9c18-f6b6ddbaf89d",
      "Name": "tenant2",
      "ConnectionStrings": {
        "Default": "...write tenant2's db connection string here..."
      }
    }
  ]

2.1.1 tenant default source

The data presented here will be as when a tenant default source, which means that confirm the current tenants, from which data will be compared with the tenants to be registered, if there is not allowed to operate.

public interface ITenantStore
{
    Task<TenantConfiguration> FindAsync(string name);

    Task<TenantConfiguration> FindAsync(Guid id);

    TenantConfiguration Find(string name);

    TenantConfiguration Find(Guid id);
}

The default storage implementation:

[Dependency(TryRegister = true)]
public class DefaultTenantStore : ITenantStore, ITransientDependency
{
    // 直接从 Options 当中获取租户数据。
    private readonly AbpDefaultTenantStoreOptions _options;

    public DefaultTenantStore(IOptionsSnapshot<AbpDefaultTenantStoreOptions> options)
    {
        _options = options.Value;
    }

    public Task<TenantConfiguration> FindAsync(string name)
    {
        return Task.FromResult(Find(name));
    }

    public Task<TenantConfiguration> FindAsync(Guid id)
    {
        return Task.FromResult(Find(id));
    }

    public TenantConfiguration Find(string name)
    {
        return _options.Tenants?.FirstOrDefault(t => t.Name == name);
    }

    public TenantConfiguration Find(Guid id)
    {
        return _options.Tenants?.FirstOrDefault(t => t.Id == id);
    }
}

In addition to reading information from a configuration file among tenants outside, developers can implement their own ITenantStoreinterfaces, such as TenantManagement as among the store tenant information to the database.

2.1.2 Based on database of tenant storage

Then connect the text, we said in Volo.ABP.TenantManagement internal module provides another ITenantStoreimplementation of the interface, this type is called TenantStore, the internal logic is very simple, is to find a tenant from data warehousing them.

public class TenantStore : ITenantStore, ITransientDependency
{
    private readonly ITenantRepository _tenantRepository;
    private readonly IObjectMapper<AbpTenantManagementDomainModule> _objectMapper;
    private readonly ICurrentTenant _currentTenant;

    public TenantStore(
        ITenantRepository tenantRepository, 
        IObjectMapper<AbpTenantManagementDomainModule> objectMapper,
        ICurrentTenant currentTenant)
    {
        _tenantRepository = tenantRepository;
        _objectMapper = objectMapper;
        _currentTenant = currentTenant;
    }

    public async Task<TenantConfiguration> FindAsync(string name)
    {
        // 变更当前租户为租主。
        using (_currentTenant.Change(null)) //TODO: No need this if we can implement to define host side (or tenant-independent) entities!
        {
            // 通过仓储查询租户是否存在。
            var tenant = await _tenantRepository.FindByNameAsync(name);
            if (tenant == null)
            {
                return null;
            }

            // 将查询到的信息转换为核心库定义的租户信息。
            return _objectMapper.Map<Tenant, TenantConfiguration>(tenant);
        }
    }

    // ... 其他的代码已经省略。
}

We can see, the last is a return TenantConfigurationtype. On this type, ABP is one of the basic types in a multi-tenant core library defined mainly attribute is used to specify a persistent tenant information needs to include.

[Serializable]
public class TenantConfiguration
{
    // 租户的 Guid。
    public Guid Id { get; set; }

    // 租户的名称。
    public string Name { get; set; }

    // 租户对应的数据库连接字符串。
    public ConnectionStrings ConnectionStrings { get; set; }

    public TenantConfiguration()
    {
        
    }

    public TenantConfiguration(Guid id, [NotNull] string name)
    {
        Check.NotNull(name, nameof(name));

        Id = id;
        Name = name;

        ConnectionStrings = new ConnectionStrings();
    }
}

2.2 tenants resolve

ABP vNext If you want to determine who is the current tenant, it is by AbpTenantResolveOptionsa group provided ITenantResolveContributorfor processing.

public class AbpTenantResolveOptions
{
    // 会使用到的这组解析对象。
    [NotNull]
    public List<ITenantResolveContributor> TenantResolvers { get; }

    public AbpTenantResolveOptions()
    {
        TenantResolvers = new List<ITenantResolveContributor>
        {
            // 默认的解析对象,会通过 Token 内字段解析当前租户。
            new CurrentUserTenantResolveContributor()
        };
    }
}

Here the same design and privileges are set by the analysis target (parser) processed in the upper layer only one inlet opening ITenantResolver, through the internal foreachimplementation of this set of analysis target Resolve()method.

Here is our ITenantResolverdefault implementation TenantResolver, you can call it at any time. For example, when you want to get the Id of the current tenants. But generally not recommended, because ABP has provided us with the MultiTenancyMiddlewaremiddleware.

image-20200301214403371

In other words, every request, this will be Idthrough the ICurrentTenant.Change()changes, then the request before execution is complete, the ICurrentTenantacquired Idwill be the parser out of Id.

public class TenantResolver : ITenantResolver, ITransientDependency
{
    private readonly IServiceProvider _serviceProvider;
    private readonly AbpTenantResolveOptions _options;

    public TenantResolver(IOptions<AbpTenantResolveOptions> options, IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _options = options.Value;
    }

    public TenantResolveResult ResolveTenantIdOrName()
    {
        var result = new TenantResolveResult();

        using (var serviceScope = _serviceProvider.CreateScope())
        {
            // 创建一个解析上下文,用于存储解析器的租户 Id 解析结果。
            var context = new TenantResolveContext(serviceScope.ServiceProvider);

            // 遍历执行解析器。
            foreach (var tenantResolver in _options.TenantResolvers)
            {
                tenantResolver.Resolve(context);

                result.AppliedResolvers.Add(tenantResolver.Name);

                // 如果有某个解析器为上下文设置了值,则跳出。
                if (context.HasResolvedTenantOrHost())
                {
                    result.TenantIdOrName = context.TenantIdOrName;
                    break;
                }
            }
        }

        return result;
    }
}

2.2.1 default analysis target

If you do not use Volo.Abp.AspNetCore.MultiTenancy module, ABP vNext will be called CurrentUserTenantResolveContributorto resolve the current operation of the tenant.

public class CurrentUserTenantResolveContributor : TenantResolveContributorBase
{
    public const string ContributorName = "CurrentUser";

    public override string Name => ContributorName;

    public override void Resolve(ITenantResolveContext context)
    {
        // 从 Token 当中获取当前登录用户的信息。
        var currentUser = context.ServiceProvider.GetRequiredService<ICurrentUser>();
        if (currentUser.IsAuthenticated != true)
        {
            return;
        }

        // 设置解析上下文,确认当前的租户 Id。
        context.Handled = true;
        context.TenantIdOrName = currentUser.TenantId?.ToString();
    }
}

Here you can see, if resolved Token from among the tenants to Id, Id will be delivered to the analytical context . In this context the very beginning have been met, if ABP vNext find tenants Id is confirmed, it will not execute the rest of the parser when parsing.

Other Parser 2.2.2 ABP offers

ABP in Volo.Abp.AspNetCore.MultiTenancy module which provides several other parsers, their roles are as follows.

Types of parsers effect priority
QueryStringTenantResolveContributor By Query String of __tenantconfirmed tenants parameters. 2
RouteTenantResolveContributor By routing to determine the current tenants. 3
HeaderTenantResolveContributor By Header inside __tenantconfirmed tenants. 4
CookieTenantResolveContributor By carrying Cookie confirmed tenants. 5
DomainTenantResolveContributor Name Resolver two, second-level domain is determined by the tenant. second

2.2.3 Name Resolver

More interesting here is that DomainTenantResolveContributordevelopers can AbpTenantResolveOptions.AddDomainTenantResolver()add this parser method. The domain will be matched by the parser resolve the two domain names corresponding tenant, for example, I assigned a second-level domain for the tenant A http://a.system.com, then this a will be parsed as a tenant name, and finally passed to ITenantResolverthe parser as a result.

note:

When using the Header information providers as tenants, developers using NGINX as a reverse proxy server , you need to configure the corresponding config file inside underscores_in_headers on;option. ABP otherwise required __tenantIdwill be filtered out, or you can specify a no underscore Key.

Code domain name resolver detailed explanation:

public class DomainTenantResolveContributor : HttpTenantResolveContributorBase
{
    public const string ContributorName = "Domain";

    public override string Name => ContributorName;

    private static readonly string[] ProtocolPrefixes = { "http://", "https://" };

    private readonly string _domainFormat;

    // 使用指定的格式来确定租户前缀,例如 “{0}.abp.io”。
    public DomainTenantResolveContributor(string domainFormat)
    {
        _domainFormat = domainFormat.RemovePreFix(ProtocolPrefixes);
    }

    protected override string GetTenantIdOrNameFromHttpContextOrNull(
        ITenantResolveContext context, 
        HttpContext httpContext)
    {
        // 如果 Host 值为空,则不进行任何操作。
        if (httpContext.Request?.Host == null)
        {
            return null;
        }

        // 解析具体的域名信息,并进行匹配。
        var hostName = httpContext.Request.Host.Host.RemovePreFix(ProtocolPrefixes);
        // 这里的 FormattedStringValueExtracter 类型是 ABP 自己实现的一个格式化解析器。
        var extractResult = FormattedStringValueExtracter.Extract(hostName, _domainFormat, ignoreCase: true);

        context.Handled = true;

        if (!extractResult.IsMatch)
        {
            return null;
        }

        return extractResult.Matches[0].Value;
    }
}

从上述代码可以知道,域名解析器是基于 HttpTenantResolveContributorBase 基类进行处理的,这个抽象基类会取得当前请求的一个 HttpContext,将这个传递与解析上下文一起传递给子类实现,由子类实现负责具体的解析逻辑。

public abstract class HttpTenantResolveContributorBase : TenantResolveContributorBase
{
    public override void Resolve(ITenantResolveContext context)
    {
        // 获取当前请求的上下文。
        var httpContext = context.GetHttpContext();
        if (httpContext == null)
        {
            return;
        }

        try
        {
            ResolveFromHttpContext(context, httpContext);
        }
        catch (Exception e)
        {
            context.ServiceProvider
                .GetRequiredService<ILogger<HttpTenantResolveContributorBase>>()
                .LogWarning(e.ToString());
        }
    }

    protected virtual void ResolveFromHttpContext(ITenantResolveContext context, HttpContext httpContext)
    {
        // 调用抽象方法,获取具体的租户 Id 或名称。
        var tenantIdOrName = GetTenantIdOrNameFromHttpContextOrNull(context, httpContext);
        if (!tenantIdOrName.IsNullOrEmpty())
        {
            // 获得到租户标识之后,填充到解析上下文。
            context.TenantIdOrName = tenantIdOrName;
        }
    }

    protected abstract string GetTenantIdOrNameFromHttpContextOrNull([NotNull] ITenantResolveContext context, [NotNull] HttpContext httpContext);
}

2.3 租户信息的传递

租户解析器通过一系列的解析对象,获取到了租户或租户 Id 之后,会将这些数据给哪些对象呢?或者说,ABP 在什么地方调用了 租户解析器,答案就是 中间件

Volo.ABP.AspNetCore.MultiTenancy 模块的内部,提供了一个 MultiTenancyMiddleware 中间件。

开发人员如果需要使用 ASP.NET Core 的多租户相关功能,也可以引入该模块。并且在模块的 OnApplicationInitialization() 方法当中,使用 IApplicationBuilder.UseMultiTenancy() 进行启用。

这里在启用的时候,需要注意中间件的顺序和位置,不要放到最末尾进行处理。

public class MultiTenancyMiddleware : IMiddleware, ITransientDependency
{
    private readonly ITenantResolver _tenantResolver;
    private readonly ITenantStore _tenantStore;
    private readonly ICurrentTenant _currentTenant;
    private readonly ITenantResolveResultAccessor _tenantResolveResultAccessor;

    public MultiTenancyMiddleware(
        ITenantResolver tenantResolver, 
        ITenantStore tenantStore, 
        ICurrentTenant currentTenant, 
        ITenantResolveResultAccessor tenantResolveResultAccessor)
    {
        _tenantResolver = tenantResolver;
        _tenantStore = tenantStore;
        _currentTenant = currentTenant;
        _tenantResolveResultAccessor = tenantResolveResultAccessor;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // 通过租户解析器,获取当前请求的租户信息。
        var resolveResult = _tenantResolver.ResolveTenantIdOrName();
        _tenantResolveResultAccessor.Result = resolveResult;

        TenantConfiguration tenant = null;
        // 如果当前请求是属于租户请求。
        if (resolveResult.TenantIdOrName != null)
        {
            // 查询指定的租户 Id 或名称是否存在,不存在则抛出异常。
            tenant = await FindTenantAsync(resolveResult.TenantIdOrName);
            if (tenant == null)
            {
                //TODO: A better exception?
                throw new AbpException(
                    "There is no tenant with given tenant id or name: " + resolveResult.TenantIdOrName
                );
            }
        }

        // 在接下来的请求当中,将会通过 ICurrentTenant.Change() 方法变更当前租户,直到
        // 请求结束。
        using (_currentTenant.Change(tenant?.Id, tenant?.Name))
        {
            await next(context);
        }
    }

    private async Task<TenantConfiguration> FindTenantAsync(string tenantIdOrName)
    {
        // 如果可以格式化为 Guid ,则说明是租户 Id。
        if (Guid.TryParse(tenantIdOrName, out var parsedTenantId))
        {
            return await _tenantStore.FindAsync(parsedTenantId);
        }
        else
        {
            return await _tenantStore.FindAsync(tenantIdOrName);
        }
    }
}

在取得了租户的标识(Id 或名称)之后,将会通过 ICurrentTenant.Change() 方法变更当前租户的信息,变更了当租户信息以后,在程序的其他任何地方使用 ICurrentTenant.Id 取得的数据都是租户解析器解析出来的数据。

下面就是这个当前租户的具体实现,可以看到这里采用了一个 经典手法-嵌套。这个手法在工作单元和数据过滤器有见到过,结合 DisposeAction()using 语句块结束的时候把当前的租户 Id 值设置为父级 Id。即在同一个语句当中,可以通过嵌套 using 语句块来处理不同的租户。

using(_currentTenant.Change("A"))
{
    Logger.LogInformation(_currentTenant.Id);
    using(_currentTenant.Change("B"))
    {
        Logger.LogInformation(_currentTenant.Id);
    }
}

具体的实现代码,这里的 ICurrentTenantAccessor 内部实现就是一个 AsyncLocal<BasicTenantInfo> ,用于在一个异步请求内部进行数据传递。

public class CurrentTenant : ICurrentTenant, ITransientDependency
{
    public virtual bool IsAvailable => Id.HasValue;

    public virtual Guid? Id => _currentTenantAccessor.Current?.TenantId;

    public string Name => _currentTenantAccessor.Current?.Name;

    private readonly ICurrentTenantAccessor _currentTenantAccessor;

    public CurrentTenant(ICurrentTenantAccessor currentTenantAccessor)
    {
        _currentTenantAccessor = currentTenantAccessor;
    }

    public IDisposable Change(Guid? id, string name = null)
    {
        return SetCurrent(id, name);
    }

    private IDisposable SetCurrent(Guid? tenantId, string name = null)
    {
        var parentScope = _currentTenantAccessor.Current;
        _currentTenantAccessor.Current = new BasicTenantInfo(tenantId, name);
        return new DisposeAction(() =>
        {
            _currentTenantAccessor.Current = parentScope;
        });
    }
}

这里的 BasicTenantInfoTenantConfiguraton 不同,前者仅用于在程序当中传递用户的基本信息,而后者是用于定于持久化的标准模型。

2.4 租户的使用

2.4.1 数据库过滤

租户的核心作用就是隔离不同客户的数据,关于过滤的基本逻辑则是存放在 AbpDbContext<TDbContext> 的。从下面的代码可以看到,在使用的时候会从注入一个 ICurrentTenant 接口,这个接口可以获得从租户解析器里面取得的租户 Id 信息。并且还有一个 IsMultiTenantFilterEnabled() 方法来判定当前 是否应用租户过滤器

public abstract class AbpDbContext<TDbContext> : DbContext, IEfCoreDbContext, ITransientDependency
    where TDbContext : DbContext
{
    protected virtual Guid? CurrentTenantId => CurrentTenant?.Id;

    protected virtual bool IsMultiTenantFilterEnabled => DataFilter?.IsEnabled<IMultiTenant>() ?? false;
        
    // ... 其他的代码。
        
    public ICurrentTenant CurrentTenant { get; set; }

    // ... 其他的代码。

    protected virtual Expression<Func<TEntity, bool>> CreateFilterExpression<TEntity>() where TEntity : class
    {
        // 定义一个 Lambda 表达式。
        Expression<Func<TEntity, bool>> expression = null;

        // 如果聚合根/实体实现了软删除接口,则构建一个软删除过滤器。
        if (typeof(ISoftDelete).IsAssignableFrom(typeof(TEntity)))
        {
            expression = e => !IsSoftDeleteFilterEnabled || !EF.Property<bool>(e, "IsDeleted");
        }

        // 如果聚合根/实体实现了多租户接口,则构建一个多租户过滤器。
        if (typeof(IMultiTenant).IsAssignableFrom(typeof(TEntity)))
        {
            // 筛选 TenantId 为 CurrentTenantId 的数据。
            Expression<Func<TEntity, bool>> multiTenantFilter = e => !IsMultiTenantFilterEnabled || EF.Property<Guid>(e, "TenantId") == CurrentTenantId;
            expression = expression == null ? multiTenantFilter : CombineExpressions(expression, multiTenantFilter);
        }

        return expression;
    }

    // ... 其他的代码。
}

2.4.2 种子数据构建

Volo.ABP.TenantManagement 模块当中,如果用户创建了一个租户,ABP 不只是在租户表插入一条新数据而已。它还会设置种子数据的 构造上下文,并且执行所有的 种子数据构建者(IDataSeedContributor)。

[Authorize(TenantManagementPermissions.Tenants.Create)]
public virtual async Task<TenantDto> CreateAsync(TenantCreateDto input)
{
    var tenant = await TenantManager.CreateAsync(input.Name);
    await TenantRepository.InsertAsync(tenant);

    using (CurrentTenant.Change(tenant.Id, tenant.Name))
    {
        //TODO: Handle database creation?

        //TODO: Set admin email & password..?
        await DataSeeder.SeedAsync(tenant.Id);
    }
    
    return ObjectMapper.Map<Tenant, TenantDto>(tenant);
}

这些构建者当中,就包括租户的超级管理员(admin)和角色构建,以及针对超级管理员角色进行权限赋值操作。

这里需要注意第二点,如果开发人员没有指定超级管理员用户和密码,那么还是会使用默认密码为租户生成超级管理员,具体原因看如下代码。

public class IdentityDataSeedContributor : IDataSeedContributor, ITransientDependency
{
    private readonly IIdentityDataSeeder _identityDataSeeder;

    public IdentityDataSeedContributor(IIdentityDataSeeder identityDataSeeder)
    {
        _identityDataSeeder = identityDataSeeder;
    }

    public Task SeedAsync(DataSeedContext context)
    {
        return _identityDataSeeder.SeedAsync(
            context["AdminEmail"] as string ?? "[email protected]",
            context["AdminPassword"] as string ?? "1q2w3E*",
            context.TenantId
        );
    }
}

所以开发人员要实现为不同租户 生成随机密码,那么就不能够使用 TenantManagement 提供的创建方法,而是需要自己编写一个应用服务进行处理。

2.4.3 权限的控制

If a developer uses ABP provides Volo.Abp.PermissionManagement module, you will see the permissions will be judged in its seeds among those data structure. Because there are some super powers are able to rent the main granted, such as tenants add, delete, modify, etc., these super-privileges defined when you need to specify whether the data is unique to the master lease.

In this regard, reference may tenant management module in the definition of rights, transfer of MultiTenancySides.Hostparameters.

public class AbpTenantManagementPermissionDefinitionProvider : PermissionDefinitionProvider
{
    public override void Define(IPermissionDefinitionContext context)
    {
        var tenantManagementGroup = context.AddGroup(TenantManagementPermissions.GroupName, L("Permission:TenantManagement"));

        var tenantsPermission = tenantManagementGroup.AddPermission(TenantManagementPermissions.Tenants.Default, L("Permission:TenantManagement"), multiTenancySide: MultiTenancySides.Host);
        tenantsPermission.AddChild(TenantManagementPermissions.Tenants.Create, L("Permission:Create"), multiTenancySide: MultiTenancySides.Host);
        tenantsPermission.AddChild(TenantManagementPermissions.Tenants.Update, L("Permission:Edit"), multiTenancySide: MultiTenancySides.Host);
        tenantsPermission.AddChild(TenantManagementPermissions.Tenants.Delete, L("Permission:Delete"), multiTenancySide: MultiTenancySides.Host);
        tenantsPermission.AddChild(TenantManagementPermissions.Tenants.ManageFeatures, L("Permission:ManageFeatures"), multiTenancySide: MultiTenancySides.Host);
        tenantsPermission.AddChild(TenantManagementPermissions.Tenants.ManageConnectionStrings, L("Permission:ManageConnectionStrings"), multiTenancySide: MultiTenancySides.Host);
    }

    private static LocalizableString L(string name)
    {
        return LocalizableString.Create<AbpTenantManagementResource>(name);
    }
}

Below are the permissions seed data structure's code:

public class PermissionDataSeedContributor : IDataSeedContributor, ITransientDependency
{
    protected ICurrentTenant CurrentTenant { get; }

    protected IPermissionDefinitionManager PermissionDefinitionManager { get; }
    protected IPermissionDataSeeder PermissionDataSeeder { get; }

    public PermissionDataSeedContributor(
        IPermissionDefinitionManager permissionDefinitionManager,
        IPermissionDataSeeder permissionDataSeeder,
        ICurrentTenant currentTenant)
    {
        PermissionDefinitionManager = permissionDefinitionManager;
        PermissionDataSeeder = permissionDataSeeder;
        CurrentTenant = currentTenant;
    }

    public virtual Task SeedAsync(DataSeedContext context)
    {
        // 通过 GetMultiTenancySide() 方法判断当前执行
        // 种子构造者的租户情况,是租主还是租户。
        var multiTenancySide = CurrentTenant.GetMultiTenancySide();
        // 根据条件筛选权限。
        var permissionNames = PermissionDefinitionManager
            .GetPermissions()
            .Where(p => p.MultiTenancySide.HasFlag(multiTenancySide))
            .Select(p => p.Name)
            .ToArray();

        // 将权限授予具体租户的角色。
        return PermissionDataSeeder.SeedAsync(
            RolePermissionValueProvider.ProviderName,
            "admin",
            permissionNames,
            context.TenantId
        );
    }
}

ABP or tenants and method is very simple in determining the main current is rented, if the current Id is NULL then the tenant rent the main, if not empty, it is a particular tenant.

public static MultiTenancySides GetMultiTenancySide(this ICurrentTenant currentTenant)
{
    return currentTenant.Id.HasValue
        ? MultiTenancySides.Tenant
        : MultiTenancySides.Host;
}

2.4.4 independent set of tenants

About this piece of content, can refer to the previous article , ABP also provides us with each tenant individually customized parameters, this function is TenantSettingManagementProvideronly required to provide tenants set the parameter value when implemented ProviderNamecan be .

E.g:

settingManager.SetAsync("WeChatIsOpen", "true", TenantSettingValueProvider.ProviderName, tenantId.ToString(), false);

Third, the summary

Other related articles, please refer to the article directories .

Guess you like

Origin www.cnblogs.com/myzony/p/12401352.html