一、簡介
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
節。
"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 預設租戶來源
這裡的資料将會作為預設租戶來源,也就是說在确認目前租戶的時候,會從這裡面的資料與要登入的租戶進行比較,如果不存在則不允許進行操作。
public interface ITenantStore
{
Task<TenantConfiguration> FindAsync(string name);
Task<TenantConfiguration> FindAsync(Guid id);
TenantConfiguration Find(string name);
TenantConfiguration Find(Guid id);
}
預設的存儲實作:
[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
,内部邏輯也很簡單,就是從倉儲當中查找租戶資料。
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);
}
}
// ... 其他的代碼已經省略。
}
可以看到,最後也是傳回的一個
TenantConfiguration
類型。關于這個類型,是 ABP 在多租戶核心庫定義的一個基本類型之一,主要是用于規定持久化一個租戶資訊需要包含的屬性。
[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
進行處理的。
public 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。
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 預設的解析對象
如果不使用 Volo.Abp.AspNetCore.MultiTenancy 子產品,ABP vNext 會調用
CurrentUserTenantResolveContributor
解析目前操作的租戶。
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();
}
}
在這裡可以看到,如果從 Token 當中解析到了租戶 Id,會将這個 Id 傳遞給 解析上下文。這個上下文在最開始已經遇到過了,如果 ABP vNext 在解析的時候發現租戶 Id 被确認了,就不會執行剩下的解析器。
2.2.2 ABP 提供的其他解析器
ABP 在 Volo.Abp.AspNetCore.MultiTenancy 子產品當中還提供了其他幾種解析器,他們的作用分别如下。
解析器類型 | 作用 | 優先級 |
---|---|---|
| 通過 Query String 的 參數确認租戶。 | 2 |
| 通過路由判斷目前租戶。 | 3 |
| 通過 Header 裡面的 确認租戶。 | 4 |
| 通過攜帶的 Cookie 确認租戶。 | 5 |
| 二級域名解析器,通過二級域名确定租戶。 | 第二 |
2.2.3 域名解析器
這裡比較有意思的是
DomainTenantResolveContributor
,開發人員可以通過
AbpTenantResolveOptions.AddDomainTenantResolver()
方法添加這個解析器。 域名解析器會通過解析二級域名來比對對應的租戶,例如我針對租戶 A 配置設定了一個二級域名
http://a.system.com
,那麼這個 a 就會被作為租戶名稱解析出來,最後傳遞給
ITenantResolver
解析器作為結果。
注意:
在使用 Header 作為租戶資訊提供者的時候,開發人員使用的是 NGINX 作為反向代理伺服器 時,需要在對應的 config 檔案内部配置
選項。否則 ABP 所需要的
underscores_in_headers on;
将會被過濾掉,或者你可以指定一個沒有下劃線的 Key。
__tenantId
域名解析器的詳細代碼解釋:
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;
});
}
}
這裡的
BasicTenantInfo
與
TenantConfiguraton
不同,前者僅用于在程式當中傳遞使用者的基本資訊,而後者是用于定于持久化的标準模型。
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 權限的控制
如果開發人員使用了 ABP 提供的 Volo.Abp.PermissionManagement 子產品,就會看到在它的種子資料構造者當中會對權限進行判定。因為有一些 超級權限 是租主才能夠授予的,例如租戶的增加、删除、修改等,這些超級權限在定義的時候就需要說明是否是資料租主獨有的。
關于這點,可以參考租戶管理子產品在權限定義時,傳遞的
MultiTenancySides.Host
參數。
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);
}
}
下面是權限種子資料構造者的代碼:
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 則說明是租主,如果不為空則說明是具體租戶。
public static MultiTenancySides GetMultiTenancySide(this ICurrentTenant currentTenant)
{
return currentTenant.Id.HasValue
? MultiTenancySides.Tenant
: MultiTenancySides.Host;
}
2.4.4 租戶的獨立設定
關于這塊的内容,可以參考之前的 這篇文章 ,ABP 也為我們提供了各個租戶獨立的自定義參數在,這塊功能是由
TenantSettingManagementProvider
實作的,隻需要在設定參數值的時候提供租戶的
ProviderName
即可。
例如:
settingManager.SetAsync("WeChatIsOpen", "true", TenantSettingValueProvider.ProviderName, tenantId.ToString(), false);
三、總結
其他相關文章,請參閱 文章目錄 。