项目

多租户架构

多租户是一种广泛应用于构建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实体。宿主和所有租户都拥有自己的用户。因此宿主端的用户TenantIdnull,而租户用户则具有对应的TenantId

提示:若实体仅适用于租户且在宿主端无意义,可在实体构造函数中强制设置TenantId不为null

何时设置TenantId?

创建新实体对象时,ABP会自动设置TenantId。该操作在基类Entity的构造函数中完成(所有其他基实体和聚合根类均派生自Entity类)。TenantId的值来自ICurrentTenant.Id属性的当前值(参见下一节)。

若为特定实体对象设置TenantId值,将覆盖基类设置的值。建议在实体类的构造函数中设置TenantId属性,且后续不再更改(实际上,更改该值意味着将实体从一个租户迁移至另一个租户,此操作需谨慎处理数据库中的关联实体)。

ICurrentTenant服务

ICurrentTenant是与多租户基础设施交互的主要服务。

ApplicationServiceDomainServiceAbpController等基类已预注入CurrentTenant属性。对于其他类型的类,可将ICurrentTenant注入到服务中。

租户属性

ICurrentTenant定义以下属性:

  • IdGuid):当前租户ID。若当前用户为宿主用户或无法从请求中确定租户,则可为null
  • Namestring):当前租户名称。若当前用户为宿主用户或无法从请求中确定租户,则可为null
  • IsAvailablebool):当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,可通过自定义租户管理模块和租户应用服务动态创建和迁移数据库,自行实现此功能。

另请参阅

在本文档中