[Abp vNext 源码分析] - 19. 多租户

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

[Abp vNext 源码分析] - 19. 多租户

myzony   2020-03-03 我要评论
## 一、简介 ABP vNext 原生支持多租户体系,可以让开发人员快速地基于框架开发 SaaS 系统。ABP vNext 实现多租户的思路也非常简单,通过一个 `TenantId` 来分割各个租户的数据,并且在查询的时候使用统一的全局过滤器(**类似于软删除**)来筛选数据。 关于多租户体系的东西,基本定义与核心逻辑存放在 **Volo.ABP.MultiTenancy** 内部。针对 ASP.NET Core MVC 的集成则是由 **Volo.ABP.AspNetCore.MultiTenancy** 项目实现的,针对多租户的解析都在这个项目内部。租户数据的存储和管理都由 **Volo.ABP.TenantManagement** 模块提供,开发人员也可以直接使用该项目快速实现多租户功能。 ## 二、源码分析 ### 2.1 启动模块 `AbpMultiTenancyModule` 模块是启用整个多租户功能的核心模块,内部只进行了一个动作,就是从配置类当中读取多租户的基本信息,以 JSON Provider 为例,就需要在 `appsettings.json` 里面有 `Tenants` 节。 ```json "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 默认租户来源 这里的数据将会作为默认租户来源,也就是说在确认当前租户的时候,会从这里面的数据与要登录的租户进行比较,如果不存在则不允许进行操作。 ```csharp public interface ITenantStore { Task FindAsync(string name); Task FindAsync(Guid id); TenantConfiguration Find(string name); TenantConfiguration Find(Guid id); } ``` 默认的存储实现: ```csharp [Dependency(TryRegister = true)] public class DefaultTenantStore : ITenantStore, ITransientDependency { // 直接从 Options 当中获取租户数据。 private readonly AbpDefaultTenantStoreOptions _options; public DefaultTenantStore(IOptionsSnapshot options) { _options = options.Value; } public Task FindAsync(string name) { return Task.FromResult(Find(name)); } public Task 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); } } ``` 除了从配置文件当中读取租户信息以外,开发人员也可以自己实现 `ITenantStore` 接口,比如说像 **TenantManagement** 一样,将租户信息存储到数据库当中。 #### 2.1.2 基于数据库的租户存储 话接上文,我们说过在 **Volo.ABP.TenantManagement** 模块内部有提供另一种 `ITenantStore` 接口的实现,这个类型叫做 `TenantStore`,内部逻辑也很简单,就是从仓储当中查找租户数据。 ```csharp public class TenantStore : ITenantStore, ITransientDependency { private readonly ITenantRepository _tenantRepository; private readonly IObjectMapper _objectMapper; private readonly ICurrentTenant _currentTenant; public TenantStore( ITenantRepository tenantRepository, IObjectMapper objectMapper, ICurrentTenant currentTenant) { _tenantRepository = tenantRepository; _objectMapper = objectMapper; _currentTenant = currentTenant; } public async Task 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` 类型。关于这个类型,是 ABP 在多租户核心库定义的一个基本类型之一,主要是用于规定持久化一个租户信息需要包含的属性。 ```csharp [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 租户的解析 ABP vNext 如果要判断当前的租户是谁,则是通过 `AbpTenantResolveOptions` 提供的一组 `ITenantResolveContributor` 进行处理的。 ```csharp public class AbpTenantResolveOptions { // 会使用到的这组解析对象。 [NotNull] public List TenantResolvers { get; } public AbpTenantResolveOptions() { TenantResolvers = new List { // 默认的解析对象,会通过 Token 内字段解析当前租户。 new CurrentUserTenantResolveContributor() }; } } ``` 这里的设计与权限一样,都是由一组 **解析对象(解析器)** 进行处理,在上层开放的入口只有一个 `ITenantResolver` ,内部通过 `foreach` 执行这组解析对象的 `Resolve()` 方法。 下面就是我们 `ITenantResolver` 的默认实现 `TenantResolver`,你可以在任何时候调用它。比如说你在想要获得当前租户 Id 的时候。不过一般不推荐这样做,因为 ABP 已经给我们提供了 **`MultiTenancyMiddleware`** 中间件。 ![image-20200301214403371]([Abp vNext 源码分析] - 19. 多租户.assets/image-20200301214403371.png) 也就是说,在每次请求的时候,都会将这个 `Id` 通过 `ICurrentTenant.Change()` 进行变更,那么在这个请求执行完成之前,通过 `ICurrentTenant` 取得的 `Id` 都会是解析器解析出来的 Id。 ```csharp public class TenantResolver : ITenantResolver, ITransientDependency { private readonly IServiceProvider _serviceProvider; private readonly AbpTenantResolveOptions _options; public TenantResolver(IOptions 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 默认的解析对象 如果不使用 **Volo.Abp.AspNetCore.MultiTenancy** 模块,ABP vNext 会调用 `CurrentUserTenantResolveContributor` 解析当前操作的租户。 ```csharp 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(); if (currentUser.IsAuthenticated != true) { return; } // 设置解析上下文,确认当前的租户 Id。 context.Handled = true; context.TenantIdOrName = currentUser.TenantId?.ToString(); } } ``` 在这里可以看到,如果从 Token 当中解析到了租户 Id,会将这个 Id 传递给 **解析上下文**。这个上下文在最开始已经遇到过了,如果 ABP vNext 在解析的时候发现租户 Id 被确认了,就不会执行剩下的解析器。 #### 2.2.2 ABP 提供的其他解析器 ABP 在 **Volo.Abp.AspNetCore.MultiTenancy** 模块当中还提供了其他几种解析器,他们的作用分别如下。 | 解析器类型 | 作用 | 优先级 | | ------------------------------------- | ---------------------------------------------- | ------ | | `QueryStringTenantResolveContributor` | 通过 Query String 的 `__tenant` 参数确认租户。 | 2 | | `RouteTenantResolveContributor` | 通过路由判断当前租户。 | 3 | | `HeaderTenantResolveContributor` | 通过 Header 里面的 `__tenant` 确认租户。 | 4 | | `CookieTenantResolveContributor` | 通过携带的 Cookie 确认租户。 | 5 | | `DomainTenantResolveContributor` | 二级域名解析器,通过二级域名确定租户。 | 第二 | #### 2.2.3 域名解析器 这里比较有意思的是 `DomainTenantResolveContributor`,开发人员可以通过 `AbpTenantResolveOptions.AddDomainTenantResolver()` 方法添加这个解析器。 域名解析器会通过解析二级域名来匹配对应的租户,例如我针对租户 A 分配了一个二级域名 `http://a.system.com`,那么这个 **a** 就会被作为租户名称解析出来,最后传递给 `ITenantResolver` 解析器作为结果。 ![](https://img2020.cnblogs.com/blog/1203160/202003/1203160-20200303113755471-1770305165.png) > 注意: > > 在使用 Header 作为租户信息提供者的时候,开发人员使用的是 **NGINX 作为反向代理服务器** 时,需要在对应的 config 文件内部配置 `underscores_in_headers on;` 选项。否则 ABP 所需要的 `__tenantId` 将会被过滤掉,或者你可以指定一个没有下划线的 Key。 **域名解析器的详细代码解释:** ```csharp 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`,将这个传递与解析上下文一起传递给子类实现,由子类实现负责具体的解析逻辑。 ```csharp 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>() .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()` 进行启用。 这里在启用的时候,需要注意中间件的顺序和位置,不要放到最末尾进行处理。 ```csharp 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 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` 语句块来处理不同的租户。 ```csharp using(_currentTenant.Change("A")) { Logger.LogInformation(_currentTenant.Id); using(_currentTenant.Change("B")) { Logger.LogInformation(_currentTenant.Id); } } ``` 具体的实现代码,这里的 `ICurrentTenantAccessor` 内部实现就是一个 `AsyncLocal` ,用于在一个异步请求内部进行数据传递。 ```csharp 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; }); } } ``` 这里的 `BasicTenantInfo` 与 `TenantConfiguraton` 不同,前者仅用于在程序当中传递用户的基本信息,而后者是用于定于持久化的标准模型。 ### 2.4 租户的使用 #### 2.4.1 数据库过滤 租户的核心作用就是隔离不同客户的数据,关于过滤的基本逻辑则是存放在 `AbpDbContext` 的。从下面的代码可以看到,在使用的时候会从注入一个 `ICurrentTenant` 接口,这个接口可以获得从租户解析器里面取得的租户 Id 信息。并且还有一个 `IsMultiTenantFilterEnabled()` 方法来判定当前 **是否应用租户过滤器**。 ```csharp public abstract class AbpDbContext : DbContext, IEfCoreDbContext, ITransientDependency where TDbContext : DbContext { protected virtual Guid? CurrentTenantId => CurrentTenant?.Id; protected virtual bool IsMultiTenantFilterEnabled => DataFilter?.IsEnabled() ?? false; // ... 其他的代码。 public ICurrentTenant CurrentTenant { get; set; } // ... 其他的代码。 protected virtual Expression> CreateFilterExpression() where TEntity : class { // 定义一个 Lambda 表达式。 Expression> expression = null; // 如果聚合根/实体实现了软删除接口,则构建一个软删除过滤器。 if (typeof(ISoftDelete).IsAssignableFrom(typeof(TEntity))) { expression = e => !IsSoftDeleteFilterEnabled || !EF.Property(e, "IsDeleted"); } // 如果聚合根/实体实现了多租户接口,则构建一个多租户过滤器。 if (typeof(IMultiTenant).IsAssignableFrom(typeof(TEntity))) { // 筛选 TenantId 为 CurrentTenantId 的数据。 Expression> multiTenantFilter = e => !IsMultiTenantFilterEnabled || EF.Property(e, "TenantId") == CurrentTenantId; expression = expression == null ? multiTenantFilter : CombineExpressions(expression, multiTenantFilter); } return expression; } // ... 其他的代码。 } ``` #### 2.4.2 种子数据构建 在 **Volo.ABP.TenantManagement** 模块当中,如果用户创建了一个租户,ABP 不只是在租户表插入一条新数据而已。它还会设置种子数据的 **构造上下文**,并且执行所有的 **种子数据构建者**(`IDataSeedContributor`)。 ```csharp [Authorize(TenantManagementPermissions.Tenants.Create)] public virtual async Task 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); } ``` 这些构建者当中,就包括租户的超级管理员(admin)和角色构建,以及针对超级管理员角色进行权限赋值操作。 这里需要注意第二点,如果开发人员没有指定超级管理员用户和密码,那么还是会使用默认密码为租户生成超级管理员,具体原因看如下代码。 ```csharp 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 ?? "admin@abp.io", context["AdminPassword"] as string ?? "1q2w3E*", context.TenantId ); } } ``` 所以开发人员要实现为不同租户 **生成随机密码**,那么就不能够使用 **TenantManagement** 提供的创建方法,而是需要自己编写一个应用服务进行处理。 #### 2.4.3 权限的控制 如果开发人员使用了 ABP 提供的 **Volo.Abp.PermissionManagement** 模块,就会看到在它的种子数据构造者当中会对权限进行判定。因为有一些 **超级权限** 是租主才能够授予的,例如租户的增加、删除、修改等,这些超级权限在定义的时候就需要说明是否是数据租主独有的。 关于这点,可以参考租户管理模块在权限定义时,传递的 `MultiTenancySides.Host` 参数。 ```csharp 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(name); } } ``` 下面是权限种子数据构造者的代码: ```csharp 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 在判断当前是租主还是租户的方法也很简单,如果当前租户 Id 为 NULL 则说明是租主,如果不为空则说明是具体租户。 ```csharp public static MultiTenancySides GetMultiTenancySide(this ICurrentTenant currentTenant) { return currentTenant.Id.HasValue ? MultiTenancySides.Tenant : MultiTenancySides.Host; } ``` #### 2.4.4 租户的独立设置 关于这块的内容,可以参考之前的 **[这篇文章](https://www.cnblogs.com/myzony/p/11730401.html)** ,ABP 也为我们提供了各个租户独立的自定义参数在,这块功能是由 `TenantSettingManagementProvider` 实现的,只需要在设置参数值的时候提供租户的 `ProviderName` 即可。 例如: ```csharp settingManager.SetAsync("WeChatIsOpen", "true", TenantSettingValueProvider.ProviderName, tenantId.ToString(), false); ``` ## 三、总结 其他相关文章,请参阅 **[文章目录](https://www.cnblogs.com/myzony/p/10722506.html)** 。

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们