[Abp vNext 源码分析] - 19. 多租户
一、簡介
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?節。
Copy"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 默認租戶來源
這里的數據將會作為默認租戶來源,也就是說在確認當前租戶的時候,會從這里面的數據與要登錄的租戶進行比較,如果不存在則不允許進行操作。
Copypublic interface ITenantStore {Task<TenantConfiguration> FindAsync(string name);Task<TenantConfiguration> FindAsync(Guid id);TenantConfiguration Find(string name);TenantConfiguration Find(Guid id); }默認的存儲實現:
Copy[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);} }除了從配置文件當中讀取租戶信息以外,開發人員也可以自己實現?ITenantStore?接口,比如說像?TenantManagement?一樣,將租戶信息存儲到數據庫當中。
2.1.2 基于數據庫的租戶存儲
話接上文,我們說過在?Volo.ABP.TenantManagement?模塊內部有提供另一種?ITenantStore?接口的實現,這個類型叫做?TenantStore,內部邏輯也很簡單,就是從倉儲當中查找租戶數據。
Copypublic 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);}}// ... 其他的代碼已經省略。 }可以看到,最后也是返回的一個?TenantConfiguration?類型。關于這個類型,是 ABP 在多租戶核心庫定義的一個基本類型之一,主要是用于規定持久化一個租戶信息需要包含的屬性。
Copy[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?進行處理的。
Copypublic class AbpTenantResolveOptions {// 會使用到的這組解析對象。[NotNull]public List<ITenantResolveContributor> TenantResolvers { get; }public AbpTenantResolveOptions(){TenantResolvers = new List<ITenantResolveContributor>{// 默認的解析對象,會通過 Token 內字段解析當前租戶。new CurrentUserTenantResolveContributor()};} }這里的設計與權限一樣,都是由一組?解析對象(解析器)?進行處理,在上層開放的入口只有一個?ITenantResolver?,內部通過?foreach?執行這組解析對象的?Resolve()?方法。
下面就是我們?ITenantResolver?的默認實現?TenantResolver,你可以在任何時候調用它。比如說你在想要獲得當前租戶 Id 的時候。不過一般不推薦這樣做,因為 ABP 已經給我們提供了?MultiTenancyMiddleware?中間件。
也就是說,在每次請求的時候,都會將這個?Id?通過?ICurrentTenant.Change()?進行變更,那么在這個請求執行完成之前,通過?ICurrentTenant?取得的?Id?都會是解析器解析出來的 Id。
Copypublic 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 默認的解析對象
如果不使用?Volo.Abp.AspNetCore.MultiTenancy?模塊,ABP vNext 會調用?CurrentUserTenantResolveContributor?解析當前操作的租戶。
Copypublic 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();} }在這里可以看到,如果從 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?解析器作為結果。
注意:
在使用 Header 作為租戶信息提供者的時候,開發人員使用的是?NGINX 作為反向代理服務器?時,需要在對應的 config 文件內部配置?underscores_in_headers on;?選項。否則 ABP 所需要的?__tenantId?將會被過濾掉,或者你可以指定一個沒有下劃線的 Key。
域名解析器的詳細代碼解釋:
Copypublic 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,將這個傳遞與解析上下文一起傳遞給子類實現,由子類實現負責具體的解析邏輯。
Copypublic 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()?進行啟用。
這里在啟用的時候,需要注意中間件的順序和位置,不要放到最末尾進行處理。
Copypublic 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?語句塊來處理不同的租戶。
Copyusing(_currentTenant.Change("A")) {Logger.LogInformation(_currentTenant.Id);using(_currentTenant.Change("B")){Logger.LogInformation(_currentTenant.Id);} }具體的實現代碼,這里的?ICurrentTenantAccessor?內部實現就是一個?AsyncLocal<BasicTenantInfo>?,用于在一個異步請求內部進行數據傳遞。
Copypublic 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<TDbContext>?的。從下面的代碼可以看到,在使用的時候會從注入一個?ICurrentTenant?接口,這個接口可以獲得從租戶解析器里面取得的租戶 Id 信息。并且還有一個?IsMultiTenantFilterEnabled()?方法來判定當前?是否應用租戶過濾器。
Copypublic abstract class AbpDbContext<TDbContext> : DbContext, IEfCoreDbContext, ITransientDependencywhere 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)。
Copy[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)和角色構建,以及針對超級管理員角色進行權限賦值操作。
這里需要注意第二點,如果開發人員沒有指定超級管理員用戶和密碼,那么還是會使用默認密碼為租戶生成超級管理員,具體原因看如下代碼。
Copypublic 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?參數。
Copypublic 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);} }下面是權限種子數據構造者的代碼:
Copypublic 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 則說明是租主,如果不為空則說明是具體租戶。
Copypublic static MultiTenancySides GetMultiTenancySide(this ICurrentTenant currentTenant) {return currentTenant.Id.HasValue? MultiTenancySides.Tenant: MultiTenancySides.Host; }2.4.4 租戶的獨立設置
關于這塊的內容,可以參考之前的?這篇文章?,ABP 也為我們提供了各個租戶獨立的自定義參數在,這塊功能是由?TenantSettingManagementProvider?實現的,只需要在設置參數值的時候提供租戶的?ProviderName?即可。
例如:
CopysettingManager.SetAsync("WeChatIsOpen", "true", TenantSettingValueProvider.ProviderName, tenantId.ToString(), false);總結
以上是生活随笔為你收集整理的[Abp vNext 源码分析] - 19. 多租户的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【复杂系统迁移 .NET Core平台系
- 下一篇: 一个迷你ASP.NET Core框架的实