多租户
“软件多租户技术指的是一种软件架构,这种架构可以使用软件的单实例运行并为多个租户提供服务。租户是通过软件实例的特定权限共享通用访问的一组用户。使用多租户架构,软件应用为每个租户提供实例的专用共享,包括实例的数据、配置、用户管理、租户的私有功能和非功能属性。多租户与多实例架构形成对比,将软件实例的行为根据不同的租户分割开来。”(维基百科)
简单来说,多租户是一种创建SaaS(软件即服务)应用的技术。
有许多不同的多租户数据库和部署方法:
这种实际上不是多租户的。但是,如果我们为每个客户使用分离的数据库运行应用的一个实例,可以在一个单独的服务器上为多租户提供服务。我们只要保证在同样的服务器环境下,应用的多个实例之间不会相互冲突。
这种情况同样适用于已经存在并没有设计为多租户的应用。创建这种对多租户没有感知的应用是容易的。但是使用这种方法需要安装、使用和维护众多的问题。
在这种方法里,在服务器上运行应用的单独实例。使用一个主数据库存储租户的元数据(如租户名称和子域),每个租户使用隔离的数据库。一旦我们呢识别出当前的租户(例如从一个子域或登录表单),然后就切换到当前租户的数据库执行操作。
在这种方法里,应用程序需要在一定程度上设计成多租户的。但是应用的大部分仍然与多租户是不相干的。
我们需要为每个租户创建和维护一个分离的数据库,包括数据库迁移。如果我们有许多专用数据库的客户,在应用升级时将会花费我们很长的时间迁移数据库模式。因为我们为租户分离了数据库,我们可以备份租户的数据库而不受其他租户的影响。如果租户需要的话,我们也可以把租户的数据库引动到一个更强劲的服务器上。
这种是最真实的多租户架构:我们只需要部署在一个单独服务器上部署应用的一个实例。我们在每个表里都有一个TenantId(或者相似的)字段用来区分租户间的数据。
这种是最易安装和维护的。但是这种应用很难创建。因为,我们必须禁止一个租户读写另一个租户的数据。我们可以天剑TenantId字段为每个数据库的读操作。如果这个实体和当前租户相关,我们可以每次写都检查。这样是冗长乏味且容易犯错误的。ABP使用自动的数据过滤来帮助我们实现。
如果有许多租户且数据庞大的话会造成性能问题。我们可以使用表分区或其他数据库特征克服这个问题。
我们想正常的在单独数据库中存储租户数据,但是也想为希望使用单独数据库的租户创建单独数据库。例如,我们把有大数据的租户存储到他们自己的数据库中,其他的租户存储在单独的一个数据库中。
最后,我们想讲应用部署在多个服务器(如web farms)上以获得更好的性能、高可用和可扩展性。这是独立于数据库方式的。
ABP可以以上面描述的任何场景方式工作
多租户默认是不可用的。我们可以在模块的PreInitialize方法按如下的方式使其可用:
- 租户:拥有自己的用户,角色,权限,设置......完全独立于其他租户使用应用程序。多租户应用有一个或多个租户。如果是一个CRM应用,不同的租户拥有他们自己的账户,联系人,产品和订单。所以当我们说一个“租户用户”的时候,指的是租户拥有的一个用户。
- 租主:租主是单例的(there is a single host)。租主负责创建和管理租户。所以,一个“租主用户”是高等级的和独立于其他所有的租户并且可以控制他们。
ABP定义了IabpSession接口获取当前用户和租户的ids。在多租户系统中,默认使用这个接口来获取当前租户的id。因此,它可以基于当前租户的id来过滤数据。我们有以下规则:
- 如果UserId和TenantId都是null,当前用户没有登录到系统。所以,我们不知道当前用户是一个租主用户还是一个租户用户。在这种情况下,用户不能方位授权内容。
- 如果UserId不为null,TenantId为null,然后我们可以知道当前用户是一个租主用户。
- 如果UserId不为null,TenantId也不为null,我们可以知道当前用户是一个租户用户。
- 如果UserId为null,TenantId不为null,意味着我们可以知道当前为租户,但当前请求没有授权(用户没有登录)。参见下一部分了解如何决定当前租户。
参见session documention章节了解更多关于session的信息。
既然所有的租户用户都使用同样的应用,我们应该有区分当前请求是哪个租户的方法。默认的会话实现按照下面给定的顺序使用不同的方法查找和当前请求相关的租户:
- 如果用户已经登录则从当前的声明中获取TenantId。声明名称为http://www.aspnetboilerplate.com/identity/claims/tenantId ,应该会包含一个整型值。如果没在声明中找到则假定当前用户是主人用户。
- 如果用户没有登录,ABP会尝试从租户解决贡献者中解决TenantId的问题你。有三个预定义的租户贡献者,以规定的顺序运行(第一个成功的解决者将会胜利):
- DomainTenantResolveContributer:尝试从URL中获取租户名称,通常是从域名或子域名。可以再模块的PreInitialize方法中配置域名形式(如 Configuration.Modules.AbpWebCommon().MultiTenancy.DomainFormat = "{0}.mydomain.com")。如果域名形式是“{0}.mydomain.com”且请求的当前主机是acme.mydomain.com,name租户名称就是“acme”。然后下一步就是根据租户名查询ItenantStore找到TenantId。如果找到了租户,就作为当前的TenantId。
- 2. HttpHeaderTenantResolveContributer:尝试从”Abp.TenantId”数据头中解决,如果存在的话(这是定义在Abp.MultiTenancy.MultiTenancyConsts.TenantIdResolveKey中的一个常数)。
- HttpCookieTenantResolveContributer:尝试从”Abp.TenantId“cookie值中解决,如果存在的话(使用上面解释的相同的常量)。
如果以上所有的尝试都没有解决TenantId,当前的请求者会被认为一个主人。租户解决者是可以扩展的。可以向 Configuration.MultiTenancy.Resolvers 集合中添加解决者,也可以从中移除解决者。
最后一个关于解决者的事情是:为了提升性能,在相同的请求中解决的租户id是被缓存的。所以,解决这在请求中只会被执行一次(且只有当前用户没有登录的时候)。
DomainTenantResolveContributer使用ItenantStore通过租户名称查找租户id。ItenantStore默认实现是NullTenantStore,它不包含任何的租户,查询的时候返回null。可以重新实现这个接口以便从任何数据源查询租户。Module Zero实现方式是从他的租户管理中获取租户。
对于多租户单数据库的方法,必须添加一个TenantId过滤器,这样从数据库里提取数据的时候只获取当前租户的实体。当实体实现了IMustHaveTenant和IMayHaveTenant两个接口中的任意一个接口时,ABP会自动过滤。
这个接口使用定义的TenantId属性区分不同租户的实体。实现IMustHaveTenant接口的实例如下:
public class Product : Entity, IMustHaveTenant { public int TenantId { get; set; } public string Name { get; set; } //...other properties }
这样,ABP知道这是一个租户特定的实体,自动和其他租户的实体隔离开。
我们可能需要在租户和主人之间共享一个实体类型。所以,一个实体可能属于一个租户或主人。ImayHaveTenant接口也定义了TenantId
(和ImustHaveTenant相似),但是子在这种情况下它是nullable。实现了此接口的示例如下:
public class Role : Entity, IMayHaveTenant { public int? TenantId { get; set; } public string RoleName { get; set; } //...other properties }
我们可能会使用相同的角色类存储主人角色和租户角色。在这种情况下,TenantId属性会判定当前实体是主人实体还是租户实体。Null意味
着是租主实体,non-null值意味着这个实体属于租户,当前值即为TenantId。
ImayHaveTenant并不如ImustHaveTenant通用。例如,产品类不能继承ImayHaveTenant,因为产品与实际应用功能关联,不予管理租户关联。所以,小心使用ImayHaveTenant接口,因为很难维护被主人和租户共享的代码。
当定义ImustHaveTenant或ImayHaveTenant的实体类型时,当创建一个新实体时(当ABP尝试从当前TenantId设置它时,在某些情况下是不可能的,尤其是对于ImayHaveTenant实体)最好总是设置TenantId。大多数的时候,这是处理TenantId属性时唯一关注的点。当写LINQ时,不需要显示的在Where条件中写TenantId,因为它会被自动过滤。
当在多租户应用程序数据库工作时,我们应该知道当前的租户。默认从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,它会从会话中获取tenantid,如之前所说。这有一些建议和最佳实践:
- 使用SetTenantId(null)切换到主人。
- 如果不是在特殊情况下,在using块中使用SetTenantId。这样,ABP会自动在using块结束时恢复tenantid,GetProducts方法可以和以前一样工作。
- 如果需要的话可以在嵌套块中使用SetTenantId。
- 既然_unitOfWorkManager.Current只能在工作单元中使用,确保代码在UOW中运行。