[Abp vNext 源码分析] - 19. 多租户
myzony 人气:0
## 一、简介
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)** 。
加载全部内容