多租户架构
多租户是一种广泛应用于构建SaaS应用程序的架构模式,其核心在于硬件与软件资源由多个客户(租户)共享。ABP框架提供了创建多租户应用程序所需的全部基础功能。
维基百科对多租户的 定义 如下:
软件多租户指的是一种软件架构,其中单个软件实例运行在服务器上,并为多个租户提供服务。租户是指共享对软件实例具有特定权限访问权限的用户群体。采用多租户架构的软件应用旨在为每个租户提供专属的实例份额,包括其数据、配置、用户管理、租户特定功能及非功能性属性。多租户与多实例架构形成对比,后者通过独立的软件实例为不同租户运作。
术语:宿主与租户
典型的SaaS/多租户应用包含两个主要角色:
- 租户是SaaS应用的客户,通过付费使用服务。
- 宿主是拥有SaaS应用并管理系统的公司。
本文档后续将统一使用"宿主"和"租户"这两个术语。
配置
AbpMultiTenancyOptions:启用/禁用多租户
AbpMultiTenancyOptions是用于启用/禁用应用多租户功能的主要配置类。
示例:启用多租户
Configure<AbpMultiTenancyOptions>(options =>
{
options.IsEnabled = true;
});
ABP默认禁用多租户功能。但使用 启动模板 创建新解决方案时,该功能默认启用。解决方案中的
MultiTenancyConsts类提供了统一控制该功能的常量。
数据库架构
ABP支持以下所有租户数据存储方案:
- 单一数据库:所有租户数据存储在同一个数据库中。
- 每租户独立数据库:每个租户拥有独立的专用数据库存储其相关数据。
- 混合模式:部分租户共享数据库,部分租户拥有独立数据库。
SaaS 模块(PRO版)允许为任意租户设置连接字符串(可选),因此可实现上述任意方案。
关于不同数据库架构的详细实现细节,可参阅社区文章《 .NET与ABP框架中的多租户独立数据库实践 》。
使用方式
多租户系统设计旨在无缝运作,并尽可能让应用代码无需感知多租户。
IMultiTenant接口
为使 实体 支持多租户,应实现IMultiTenant接口。
示例:多租户商品实体
using System;
using Volo.Abp.Domain.Entities;
using Volo.Abp.MultiTenancy;
namespace MultiTenancyDemo.Products
{
public class Product : AggregateRoot<Guid>, IMultiTenant
{
public Guid? TenantId { get; set; } //由IMultiTenant接口定义
public string Name { get; set; }
public float Price { get; set; }
}
}
IMultiTenant接口仅定义TenantId属性。实现此接口后,ABP在从数据库查询时会自动 数据过滤 当前租户的实体。因此无需在查询时手动添加TenantId条件,默认情况下租户无法访问其他租户的数据。
为何TenantId属性可为空?
IMultiTenant.TenantId为可空类型。当其值为null时,表示实体由宿主端拥有,不属于任何租户。这对于系统中同时被租户和宿主使用的功能非常有用。
例如,身份模块 定义的IdentityUser实体。宿主和所有租户都拥有自己的用户。因此宿主端的用户TenantId为null,而租户用户则具有对应的TenantId。
提示:若实体仅适用于租户且在宿主端无意义,可在实体构造函数中强制设置
TenantId不为null。
何时设置TenantId?
创建新实体对象时,ABP会自动设置TenantId。该操作在基类Entity的构造函数中完成(所有其他基实体和聚合根类均派生自Entity类)。TenantId的值来自ICurrentTenant.Id属性的当前值(参见下一节)。
若为特定实体对象设置TenantId值,将覆盖基类设置的值。建议在实体类的构造函数中设置TenantId属性,且后续不再更改(实际上,更改该值意味着将实体从一个租户迁移至另一个租户,此操作需谨慎处理数据库中的关联实体)。
ICurrentTenant服务
ICurrentTenant是与多租户基础设施交互的主要服务。
ApplicationService、DomainService、AbpController等基类已预注入CurrentTenant属性。对于其他类型的类,可将ICurrentTenant注入到服务中。
租户属性
ICurrentTenant定义以下属性:
Id(Guid):当前租户ID。若当前用户为宿主用户或无法从请求中确定租户,则可为null。Name(string):当前租户名称。若当前用户为宿主用户或无法从请求中确定租户,则可为null。IsAvailable(bool):当Id不为null时返回true。
更改当前租户
ABP基于ICurrentTenant.Id自动过滤资源(数据库、缓存等)。但在某些情况下(通常在宿主上下文中)可能需要代表特定租户执行操作。
ICurrentTenant.Change方法可在限定范围内更改当前租户,从而安全地执行租户操作。
示例:获取特定租户的商品数量
using System;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Domain.Services;
namespace MultiTenancyDemo.Products
{
public class ProductManager : DomainService
{
private readonly IRepository<Product, Guid> _productRepository;
public ProductManager(IRepository<Product, Guid> productRepository)
{
_productRepository = productRepository;
}
public async Task<long> GetProductCountAsync(Guid? tenantId)
{
using (CurrentTenant.Change(tenantId))
{
return await _productRepository.GetCountAsync();
}
}
}
}
Change方法可嵌套使用。using语句结束后会将CurrentTenant.Id恢复为原值。- 在
Change作用域内使用CurrentTenant.Id时,将获得传递给Change方法的tenantId。因此仓储也能获取此tenantId并相应过滤数据库查询。 - 使用
CurrentTenant.Change(null)可切换至宿主上下文。
务必如示例所示,通过
using语句使用Change方法。
数据过滤:禁用多租户过滤器
如前所述,ABP使用 数据过滤 系统处理租户间数据隔离。某些情况下可能需要禁用它,以查询所有数据(不按当前租户过滤)。
示例:获取数据库中所有租户的商品总数
using System;
using System.Threading.Tasks;
using Volo.Abp.Data;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Domain.Services;
using Volo.Abp.MultiTenancy;
namespace MultiTenancyDemo.Products
{
public class ProductManager : DomainService
{
private readonly IRepository<Product, Guid> _productRepository;
private readonly IDataFilter _dataFilter;
public ProductManager(
IRepository<Product, Guid> productRepository,
IDataFilter dataFilter)
{
_productRepository = productRepository;
_dataFilter = dataFilter;
}
public async Task<long> GetProductCountAsync()
{
using (_dataFilter.Disable<IMultiTenant>())
{
return await _productRepository.GetCountAsync();
}
}
}
}
详见 数据过滤文档 。
注意:若租户使用独立数据库,此方法无效,因为无法通过单一数据库查询多个数据库。如需此功能需自行处理。
基础设施
确定当前租户
多租户应用的首要任务是在运行时确定当前租户。
ABP为此提供了可扩展的租户解析系统。租户解析系统随后被多租户中间件用于确定当前HTTP请求的租户。
租户解析器
默认租户解析器
以下解析器默认提供并配置:
CurrentUserTenantResolveContributor:若当前用户已登录,则从其声明中获取租户ID。出于安全考虑,此解析器应始终为首位。QueryStringTenantResolveContributor:尝试从查询字符串参数获取当前租户ID。默认参数名为__tenant。RouteTenantResolveContributor:尝试从路由(URL路径)获取当前租户ID。默认变量名为__tenant。若路由中定义此变量,则可从路由确定当前租户。HeaderTenantResolveContributor:尝试从HTTP头获取当前租户ID。默认头名称为__tenant。CookieTenantResolveContributor:尝试从Cookie值获取当前租户ID。默认Cookie名为__tenant。
NGINX相关问题
若使用 nginx 作为反向代理服务器,可能在HTTP头中使用 __tenant 时遇到问题。因为nginx不允许在HTTP头中使用下划线及其他特殊字符,需手动配置。参见:
http://nginx.org/en/docs/http/ngx_http_core_module.html#ignore_invalid_headers
http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
AbpAspNetCoreMultiTenancyOptions
可通过 AbpAspNetCoreMultiTenancyOptions 更改 __tenant 参数名。
示例:
services.Configure<AbpAspNetCoreMultiTenancyOptions>(options =>
{
options.TenantKey = "MyTenantKey";
});
若更改TenantKey,需确保通过Angular客户端的withOptions方法将其传递至provideAbpCore:
// app.config.ts
// ...
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideAbpCore(
withOptions({
// ...
tenantKey: "MyTenantKey",
})
),
// ...
],
};
如需访问,可按如下方式注入:
import { Inject } from '@angular/core';
import { TENANT_KEY } from '@abp/ng.core';
class SomeComponent {
constructor(@Inject(TENANT_KEY) private tenantKey: string) {}
}
但不建议更改此值,因为部分客户端可能假定参数名为
__tenant,届时需手动配置。
MultiTenancyMiddlewareErrorPageBuilder用于处理非活动及不存在的租户。
默认响应错误页面,可根据需要更改,例如仅输出错误日志并继续ASP.NET Core请求管道。
Configure<AbpAspNetCoreMultiTenancyOptions>(options =>
{
options.MultiTenancyMiddlewareErrorPageBuilder = async (context, exception) =>
{
// 处理异常
// 返回true停止管道,false继续
return true;
};
});
域名/子域名租户解析器
实际应用中,通常希望通过子域名(如mytenant1.mydomain.com)或完整域名(如mytenant.com)确定当前租户。可通过配置AbpTenantResolveOptions添加域名租户解析器。
示例:添加子域名解析器
Configure<AbpTenantResolveOptions>(options =>
{
options.AddDomainTenantResolver("{0}.mydomain.com");
});
{0}为占位符,用于确定当前租户的唯一名称。- 将此代码添加到 模块 的
ConfigureServices方法中。 - 因URL与Web相关,此操作应在Web/API层完成。
ABP(自v6.0起)默认使用OpenIddict作为认证服务器。使用OpenIddict时,还需在PreConfigure方法中添加此代码:
// using Volo.Abp.OpenIddict.WildcardDomains
PreConfigure<AbpOpenIddictWildcardDomainOptions>(options =>
{
options.EnableWildcardDomainSupport = true;
options.WildcardDomainsFormat.Add("https://{0}.mydomain.com");
});
同时需在Configure方法中添加:
// using Volo.Abp.MultiTenancy;
Configure<AbpTenantResolveOptions>(options =>
{
options.AddDomainTenantResolver("{0}.mydomain.com");
});
此 示例 展示了使用子域名确定当前租户的方法。
若使用分离式认证服务器,需在HTTPApi.Host项目中安装[Owl.TokenWildcardIssuerValidator](https://www.nuget.org/packages/Owl.TokenWildcardIssuerValidator):
dotnet add package Owl.TokenWildcardIssuerValidator
随后修正.AddJwtBearer块的配置:
// using using Owl.TokenWildcardIssuerValidator;
context.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata = Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]);
options.Audience = "ExampleProjectName";
// 新增代码块开始
options.TokenValidationParameters.IssuerValidator = TokenWildcardIssuerValidator.IssuerValidator;
options.TokenValidationParameters.ValidIssuers = new[]
{
"https://{0}.mydomain.com:44349/" //端口可能不同
};
// 新增代码块结束
});
自定义租户解析器
可实现自定义租户解析器,并在模块的ConfigureServices方法中配置AbpTenantResolveOptions:
Configure<AbpTenantResolveOptions>(options =>
{
options.TenantResolvers.Add(new MyCustomTenantResolveContributor());
});
MyCustomTenantResolveContributor必须继承TenantResolveContributorBase(或实现ITenantResolveContributor),如下所示:
using System.Threading.Tasks;
using Volo.Abp.MultiTenancy;
namespace MultiTenancyDemo.Web
{
public class MyCustomTenantResolveContributor : TenantResolveContributorBase
{
public override string Name => "Custom";
public override Task ResolveAsync(ITenantResolveContext context)
{
//TODO...
}
}
}
- 若解析器能确定租户,应设置
context.TenantIdOrName。否则保持原状,允许后续解析器处理。 - 若需从 依赖注入 系统解析其他服务,可使用
context.ServiceProvider。
后备租户
若希望始终回退至某个租户(当租户解析逻辑未找到租户时),可设置AbpTenantResolveOptions.FallbackTenant选项:
Configure<AbpTenantResolveOptions>(options =>
{
options.FallbackTenant = "acme";
});
FallbackTenant值可为租户名称或ID。此选项在开发阶段或特定场景中有助于为应用设置固定租户,是确保租户上下文始终可用的简单一致的方法。但设置后无法切换至宿主端。虽然大多数情况下不需要,但必要时可使用此解析逻辑。
多租户中间件
多租户中间件是ASP.NET Core请求管道中的 中间件 ,用于从HTTP请求确定当前租户并设置ICurrentTenant属性。
多租户中间件通常置于 认证 中间件(app.UseAuthentication())之后:
app.UseMultiTenancy();
启动模板中已配置此中间件,通常无需手动添加。
租户存储
ITenantStore用于从数据源获取租户配置。
租户名称不区分大小写。
ITenantStore使用NormalizedName参数获取租户,需通过ITenantNormalizer规范化租户名称。
租户管理模块
租户管理模块 已包含在启动模板中,并实现ITenantStore接口以从数据库获取租户及其配置。同时提供必要的功能和UI管理租户。
配置数据存储
若不使用租户管理模块,则使用DefaultTenantStore作为ITenantStore实现。它从 配置系统(IConfiguration)获取租户配置。可配置AbpDefaultTenantStoreOptions 选项 或在appsettings.json文件中设置:
示例:在appsettings.json中定义租户
"Tenants": [
{
"Id": "446a5211-3d72-4339-9adc-845151f8ada0",
"Name": "tenant1",
"NormalizedName": "TENANT1"
},
{
"Id": "25388015-ef1c-4355-9c18-f6b6ddbaf89d",
"Name": "tenant2",
"NormalizedName": "TENANT2",
"ConnectionStrings": {
"Default": "...此处填写租户2的数据库连接字符串..."
}
}
]
推荐使用租户管理模块,该模块在通过ABP启动模板创建新应用时已预配置。
其他多租户基础设施
ABP设计时考虑了多租户的各个方面,大多数情况下一切都会按预期工作。
BLOB存储、缓存、数据过滤、数据播种、授权及其他所有服务均设计为可在多租户系统中正常运行。
租户管理模块
ABP提供创建多租户应用的全部基础设施,但不预设租户管理(创建、删除...)方式。
租户管理模块 提供管理租户的基础UI。应用启动模板 中已预配置该模块。
关于开源版中每租户独立数据库方案的说明
虽然ABP完全支持此选项,但从UI管理租户连接字符串的功能在开源版中不可用。需拥有 SaaS 模块(PRO版)。 Alternatively,可通过自定义租户管理模块和租户应用服务动态创建和迁移数据库,自行实现此功能。
抠丁客


