1.5 ABP总体介绍 - 多租户

1.5.1 什么是多租户

维基百科:“软件多租户是指一个软件架构的实例软件运行在一个服务器上,但存在多个租户。租户是一组共享一个公共的用户访问特定权限的软件实例。多租户架构,软件应用程序旨在提供每个租户专用的实例包括数据、配置、用户管理、租户个体功能和非功能属性。多租户与多实例架构,独立的软件实例代表不同的租户”操作多租户一般用来创建SaaS(软件即服务)应用程序(云计算):

简而言之,多租户是一种用于创建SaaS (软件即服务)应用程序的技术。

1.5.2 多个部署多个数据库

这实际上并不是多租户,如果为每个客户(租户)配置一个单独的数据库和应用程序的一个实例,即在单个服务器中部署但提供给多个客户(租户)使用,我们需要确保应用程序的多个实例不会因为系统相同的配置环境而发生冲突。

这种已有的设计方式也不是真正为多租户服务的,它的好处是更容易的创建,但是存在一些安装、使用和维护的问题。

1.5.3 单个部署多个数据库

使用这种方式,我们可以在服务器上运行应用程序的一个实例。我们有一个主数据库用来存储租户的数据(例如:租户名称以及子域名)以及每个租户的单个数据库。一旦我们识别出当前租户(例如:从子域名或者用户登录的信息来判定),那么我们可以切换到当前租户的数据库来执行操作。

以这种方式设计出来的应用程序,在某种程度上可以被看做多租户。但是大多数的应用仍然依赖于多租户。

我们应该为每个租户创建和维护它们自己单独的数据库,这包括数据迁移。如果我们有很多的客户以及与之相应的数据库,在更新应用程序的时候,那会花费太多的时间在数据库架构的迁移上。当然这样做,我们为租户分离出了数据库,我们可以为每个租户备份它们自己的数据库。如果租户需要的话,我们可以将租户的数据库迁移到更强大的服务器上。

1.5.4 单个部署单个数据库

这是真正的多租户构架,我们只在服务器上部署应用程序的单一实例且只有一个数据库。在各表中使用TenantId来隔离其它租户的信息。

这样的好处是易于安装和维护,但创建这样的一个应用程序比较困难。因为,需要防止租户读写其它租户的信息。在用户读取数据时候可以添加TenantId过滤器过滤数据,同样,系统会检测用户的写入操作。这是很繁琐的,而且也容易出错。ABP可以帮助我们自动数据过滤。

如果我们有很多租户并且数据量巨大,那么这种实现方式将会导致一些性能问题。我们可以使用表分区或者数据库的其它功能来克服这些问题。

1.5.5 单部署混合数据库

通常我们可能想存储租户到一个单独的数据库中,但是也可能想为租户创建分离的数据库。例如:我们可以为那些数据量巨大的租户创建单独的数据库,但是其它租户仍使用同一个数据库。

1.5.6 多部署-单/多/混合数据库

最后,为了达到更好的性能,高可用性以及伸缩性;我们可能想要部署我们的应用到多个服务器上。这些都是依赖于数据库的方式。

1.5.7 ABP中的多租户

ABP可以工作于所有上面所描述的场景。

1. 开启多租户

默认多租户是被禁用的,我们需要在模块的 PreInitialize 方法中开启它,如下所示:

Configuration.MultiTenancy.IsEnabled = true;

2. Host VS 租户 宿主与租户

首先,我们先定义两个多租户系统中的术语:

  • 租户:客户有它自己的用户,角色,权限,设置…并使用应用程序与其他租户完全隔离。多租户应用程序将有一个或多个租户。如果这是一个CRM应用程序,不同的租户也他们自己的帐户、联系人、产品和订单。所以,当我们说一个租户的用户,我们的意思是用户拥有的租户。

  • Host: Host是单例的(只有唯一一个Host).Host负责创建和管理租户。所以Host用户独立与租户且可以控制租户。

3. Session

ABP定义IAbpSession接口来获取当前用户和租户id。这个接口中使用多租户当前租户的id。因此,它可以基于当前租户的id过滤数据。

这里有一些规则:

  • 如果两个用户id和TenantId是null,那么当前用户没有登录到系统中。所以,我们不知道这是一个主机用户或租户的用户。在这种情况下,用户不能访问授权的内容。

  • 用户id(如果不为空,TenantId为空的,然后我们可以知道当前用户是一个主机用户。

  • 用户id(如果不为空,TenantId也不为空,我们可以知道当前用户是一个租户的用户。

有关更多的Session内容可查看:Session

4. 当前租户的断定

由于所有的租户用户都是使用了相同的应用程序,我们应该有一种方式来区分当前请求的租户。默认会话(ClaimsAbpSession)用给定的顺序实现了使用不同的方式来找到当前请求相关的租户:

  • 1. 如果用户已经登录,那么从当前声明(claims)中取得租户ID,声明的唯一名字是:http://www.52abp.com/identity/claims/tenantId 并且该声明应该包含一个整型值。如果在声明中没有发现,那么该用户被假设为Host用户。

  • 2. 如果用户没有登录,那么它会尝试从 tenant resolve contributors(暂翻译为:租户解析参与者) 中解析租户ID。这里有3种预定义的租户参与者,并按照给定的顺序运行(第一个解析成功的解析器获胜):

    • 1. DomainTenantResolveContributer:尝试从url中解析租户名,通常来说是域名或者子域名。在模块的预初始化(PreInitialize)中可以配置域名格式(例如:Configuration.Modules.AbpWebCommon().MultiTenancy.DomainFormat = "{0}.mydomain.com";)。如果域名的格式是 "{0}.mydomain.com",并且当前请求的域名是:acme.mydomain.com,那么租户名被解析为 acme。那么下一步就是通过 ITenantStore 用给定的租户名来查找租户ID,如果租户被发现,那么该租户ID就是当前租户的ID。

    • 2. HttpHeaderTenantResolveContributer:如果存在 Abp.TenantId 请求头(这个常量被定义在Abp.MultiTenancy.MultiTenancyConsts.TenantIdResolveKey),那么尝试从该请求头中解析租户ID。

    • 3. HttpCookieTenantResolveContributer:如果存在 Abp.TenantId 的cookie值(这个常量被定义在Abp.MultiTenancy.MultiTenancyConsts.TenantIdResolveKey),那么就从该cookie中解析租户ID。

如果上述方式都没有解析得到租户ID,那么当前的请求会被考虑作为Host请求。租户解析器是可扩展的。你可以添加解析器到集合:Configuration.MultiTenancy.Resolvers,或者移除某个存在的解析器。

关于解析租户ID的最后一件事情是:为了性能优化,解析的租户ID被缓存在相同的请求中。所以,在同一个请求中解析仅被执行一次(当且仅当该用户没有登录)。

5. Tenant Store 租户商店

DomainTenantResolveContributer 使用 ITenantStore 通过租户名来查找租户ID。NullTenantStore 默认实现了 ITenantStore 接口,但是它不包含任何租户,对于查询仅仅返回null值。当你需要从数据源中查询时,你可以实现并替换它。在 Module ZeroTenant Manager 中已经实现了该扩展。所以,如果你使用了module zero,那么你不需要关心tenant store。

6. 数据过滤器

当我们从数据库检索实体,我们必须添加一个TenantId过滤当前的租户实体。当你实现了接口:IMustHaveTenant或IMayHaveTenant中的一个时,ABP将自动完成数据过滤。

IMustHaveTenant Interface

这个接口通过TenantId属性来区分不同的租户的实体。示例:

public class Product : Entity, IMustHaveTenant
{
    public int TenantId { get; set; }
        
    public string Name { get; set; }
    
    //...其它属性
}

因此,ABP能发现这是一个与租户相关的实体,并自动隔离其它租户的实体。

IMayHaveTenant interface

我们可能需要在Host和租户之间共享实体类型。一个实体可能属于租户或Host,IMayHaveTenant接口还定义了TenantId(类似于IMustHaveTenant),但在这种情况下可以为空。示例如下:

public class Role : Entity, IMayHaveTenant
{
    public int? TenantId { get; set; }
        
    public string RoleName { get; set; }
    
    //...其它属性
}

我们可以使用相同的角色类存储主机角色和租户的角色。在这种情况下,TenantId属性会告诉我们这是一个Host实体还是一个租户实体。null 值意味着这是一个 Host实体 ,一个 非空值 意味着这被一个租户拥有,该租户的Id是 TenantId

备注

IMayHaveTenant不像IMustHaveTenant一样常用。比如,一个Product类可以不实现IMayHaveTenant接口,因为Product和实际的应用功能相关,和管理租户不相干。因此,要小心使用IMayHaveTenant接口,因为它更难维护租户和租主共享的代码。

当你定义一个实体类型实现了 IMustHaveTenant 或者 IMayHaveTenant 接口的时候;那么在创建一个新实体的时候,你就需要设置 TenantId 的值,(ABP会尝试把当前AbpSession的TenantId的值设置给它,但是在某些情况下这是不可能的,尤其是实现了IMayHaveTenant接口的实体)。在大多数时候,这是唯一一个地方你需要处理TenantI的地方,但是在其它对租户数据过滤的时候,你不需要在写Linq的where条件语句的时候明确指出TenantId,因为它会自动的实现过滤。

在Host和租户之间的切换

当在多租户应用数据库上工作的时候,我们应该知道当前的租。默认获取租户ID的方式是从 IAbpSession 上获取的。我们可以改变这个行为并且切换到其它租户的数据库上。例如:

public class ProductService : ITransientDependency
{
    private readonly IRepository<Product> _productRepository;
    private readonly IUnitOfWorkManager _unitOfWorkManager;

    public ProductService(IRepository<Product> productRepository, IUnitOfWorkManager unitOfWorkManager)
    {
        _productRepository = productRepository;
        _unitOfWorkManager = unitOfWorkManager;
    }

    [UnitOfWork]
    public virtual List<Product> GetProducts(int tenantId)
    {
        using (_unitOfWorkManager.Current.SetTenantId(tenantId))
        {
            return _productRepository.GetAllList();
        }
    }
}

SetTenantId 方法确保我们得到的数据是指定租户的数据,这依赖于数据库架构:

  • 如果给定的租户有特定的数据库,那么切换到这个数据库并且从该数据库中取得产品数据

  • 如果给定的租户没有特定的数据库(例如:单数据库方式),它会自动的添加TenantId条件到查询语句来过滤数据获取指定的租户的产品数据

如果我们没有使用SetTenantId方法,它会从Session中取得租户Id,如同之前所述。

这里有一些关于最佳实践的建议:

  • 使用 SetTenantId(null) 切换到Host

  • 如果没有特别的原因,你应该像上面示例所展示的一样,在using语句块中使用SetTenantId方法。因为它会在using语句块后且在 GetProducts 方法工作完成之前,自动的还原TenantId (也就是说using语句块运行完后,TenantId是从Session中获取的不会是来自于GetProducts的传入参数)

  • 如果需要你可以嵌套使用SetTenantId方法

  • 因为 _unitOfWorkManager.Current 仅在工作单元中有效,请确保你的代码是在工作单元中运行