唐宋源码清 2020-03-03
ABP vNext 原生支持多租户体系,可以让开发人员快速地基于框架开发 SaaS 系统。ABP vNext 实现多租户的思路也非常简单,通过一个 TenantId 来分割各个租户的数据,并且在查询的时候使用统一的全局过滤器(类似于软删除)来筛选数据。
关于多租户体系的东西,基本定义与核心逻辑存放在 Volo.ABP.MultiTenancy 内部。针对 ASP.NET Core MVC 的集成则是由 Volo.ABP.AspNetCore.MultiTenancy 项目实现的,针对多租户的解析都在这个项目内部。租户数据的存储和管理都由 Volo.ABP.TenantManagement 模块提供,开发人员也可以直接使用该项目快速实现多租户功能。
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..."
}
}
]这里的数据将会作为默认租户来源,也就是说在确认当前租户的时候,会从这里面的数据与要登录的租户进行比较,如果不存在则不允许进行操作。
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 一样,将租户信息存储到数据库当中。
话接上文,我们说过在 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();
}
}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 中间件。
![[Abp vNext 源码分析] - 19. 多租户 [Abp vNext 源码分析] - 19. 多租户](https://cdn.ancii.com/article/image/v1/wM/BF/zW/WzBMFwO38B3FnKOXBEDpwQER2m6hs2ISjcTrKhJcqGDe3jLnYfrW8Jo8G41rlx0lHFK9AAEk3Z6uIKhZ-neolWvbBEoESccXGV3DXayJeENsHrezsew0OhUX7u4xkE9_DY-M_IvNR7HYeJF-ZHgPWF9xpEIu1oK7U-NHYIZacPA3-FZQsarlacq_eBrulsbzhJk8R56VjkvGysfLrfxwsw.png)
也就是说,在每次请求的时候,都会将这个 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;
}
}如果不使用 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 被确认了,就不会执行剩下的解析器。
ABP 在 Volo.Abp.AspNetCore.MultiTenancy 模块当中还提供了其他几种解析器,他们的作用分别如下。
| 解析器类型 | 作用 | 优先级 |
|---|---|---|
QueryStringTenantResolveContributor | 通过 Query String 的 __tenant 参数确认租户。 | 2 |
RouteTenantResolveContributor | 通过路由判断当前租户。 | 3 |
HeaderTenantResolveContributor | 通过 Header 里面的 __tenant 确认租户。 | 4 |
CookieTenantResolveContributor | 通过携带的 Cookie 确认租户。 | 5 |
DomainTenantResolveContributor | 二级域名解析器,通过二级域名确定租户。 | 第二 |
这里比较有意思的是 DomainTenantResolveContributor,开发人员可以通过 AbpTenantResolveOptions.AddDomainTenantResolver() 方法添加这个解析器。 域名解析器会通过解析二级域名来匹配对应的租户,例如我针对租户 A 分配了一个二级域名 http://a.system.com,那么这个 a 就会被作为租户名称解析出来,最后传递给 ITenantResolver 解析器作为结果。
![[Abp vNext 源码分析] - 19. 多租户 [Abp vNext 源码分析] - 19. 多租户](https://cdn.ancii.com/article/image/v1/sw/wV/kP/PkwwVsGDmjDG9swnOi7SkjVAsMQAarn73E9S3mmSmcDyheIzFoYJkK7h-qOdOAoPn2WxCXVRG1jDYM1BJKPMnA.png)
注意:
在使用 Header 作为租户信息提供者的时候,开发人员使用的是 NGINX 作为反向代理服务器 时,需要在对应的 config 文件内部配置
underscores_in_headers on;选项。否则 ABP 所需要的__tenantId将会被过滤掉,或者你可以指定一个没有下划线的 Key。
域名解析器的详细代码解释:
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);
}租户解析器通过一系列的解析对象,获取到了租户或租户 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 不同,前者仅用于在程序当中传递用户的基本信息,而后者是用于定于持久化的标准模型。
租户的核心作用就是隔离不同客户的数据,关于过滤的基本逻辑则是存放在 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;
}
// ... 其他的代码。
}在 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 ?? "",
context["AdminPassword"] as string ?? "1q2w3E*",
context.TenantId
);
}
}所以开发人员要实现为不同租户 生成随机密码,那么就不能够使用 TenantManagement 提供的创建方法,而是需要自己编写一个应用服务进行处理。
如果开发人员使用了 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;
}关于这块的内容,可以参考之前的 这篇文章 ,ABP 也为我们提供了各个租户独立的自定义参数在,这块功能是由 TenantSettingManagementProvider 实现的,只需要在设置参数值的时候提供租户的 ProviderName 即可。
例如:
settingManager.SetAsync("WeChatIsOpen", "true", TenantSettingValueProvider.ProviderName, tenantId.ToString(), false);其他相关文章,请参阅 文章目录 。