[Abp vNext 源码分析] - 19. 多租户
一、简介
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
节。
"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 默认租户来源
这里的数据将会作为默认租户来源,也就是说在确认当前租户的时候,会从这里面的数据与要登录的租户进行比较,如果不存在则不允许进行操作。
public interface itenantstore { task<tenantconfiguration> findasync(string name); task<tenantconfiguration> findasync(guid id); tenantconfiguration find(string name); tenantconfiguration find(guid id); }
默认的存储实现:
[dependency(tryregister = true)] public class defaulttenantstore : itenantstore, itransientdependency { // 直接从 options 当中获取租户数据。 private readonly abpdefaulttenantstoreoptions _options; public defaulttenantstore(ioptionssnapshot<abpdefaulttenantstoreoptions> options) { _options = options.value; } public task<tenantconfiguration> findasync(string name) { return task.fromresult(find(name)); } public task<tenantconfiguration> 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
,内部逻辑也很简单,就是从仓储当中查找租户数据。
public class tenantstore : itenantstore, itransientdependency { private readonly itenantrepository _tenantrepository; private readonly iobjectmapper<abptenantmanagementdomainmodule> _objectmapper; private readonly icurrenttenant _currenttenant; public tenantstore( itenantrepository tenantrepository, iobjectmapper<abptenantmanagementdomainmodule> objectmapper, icurrenttenant currenttenant) { _tenantrepository = tenantrepository; _objectmapper = objectmapper; _currenttenant = currenttenant; } public async task<tenantconfiguration> 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>(tenant); } } // ... 其他的代码已经省略。 }
可以看到,最后也是返回的一个 tenantconfiguration
类型。关于这个类型,是 abp 在多租户核心库定义的一个基本类型之一,主要是用于规定持久化一个租户信息需要包含的属性。
[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
进行处理的。
public class abptenantresolveoptions { // 会使用到的这组解析对象。 [notnull] public list<itenantresolvecontributor> tenantresolvers { get; } public abptenantresolveoptions() { tenantresolvers = new list<itenantresolvecontributor> { // 默认的解析对象,会通过 token 内字段解析当前租户。 new currentusertenantresolvecontributor() }; } }
这里的设计与权限一样,都是由一组 解析对象(解析器) 进行处理,在上层开放的入口只有一个 itenantresolver
,内部通过 foreach
执行这组解析对象的 resolve()
方法。
下面就是我们 itenantresolver
的默认实现 tenantresolver
,你可以在任何时候调用它。比如说你在想要获得当前租户 id 的时候。不过一般不推荐这样做,因为 abp 已经给我们提供了 multitenancymiddleware
中间件。
也就是说,在每次请求的时候,都会将这个 id
通过 icurrenttenant.change()
进行变更,那么在这个请求执行完成之前,通过 icurrenttenant
取得的 id
都会是解析器解析出来的 id。
public class tenantresolver : itenantresolver, itransientdependency { private readonly iserviceprovider _serviceprovider; private readonly abptenantresolveoptions _options; public tenantresolver(ioptions<abptenantresolveoptions> 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
解析当前操作的租户。
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<icurrentuser>(); 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
解析器作为结果。
注意:
在使用 header 作为租户信息提供者的时候,开发人员使用的是 nginx 作为反向代理服务器 时,需要在对应的 config 文件内部配置
underscores_in_headers on;
选项。否则 abp 所需要的__tenantid
将会被过滤掉,或者你可以指定一个没有下划线的 key。
域名解析器的详细代码解释:
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
,将这个传递与解析上下文一起传递给子类实现,由子类实现负责具体的解析逻辑。
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<ilogger<httptenantresolvecontributorbase>>() .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()
进行启用。
这里在启用的时候,需要注意中间件的顺序和位置,不要放到最末尾进行处理。
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<tenantconfiguration> 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
语句块来处理不同的租户。
using(_currenttenant.change("a")) { logger.loginformation(_currenttenant.id); using(_currenttenant.change("b")) { logger.loginformation(_currenttenant.id); } }
具体的实现代码,这里的 icurrenttenantaccessor
内部实现就是一个 asynclocal<basictenantinfo>
,用于在一个异步请求内部进行数据传递。
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<tdbcontext>
的。从下面的代码可以看到,在使用的时候会从注入一个 icurrenttenant
接口,这个接口可以获得从租户解析器里面取得的租户 id 信息。并且还有一个 ismultitenantfilterenabled()
方法来判定当前 是否应用租户过滤器。
public abstract class abpdbcontext<tdbcontext> : dbcontext, iefcoredbcontext, itransientdependency where tdbcontext : dbcontext { protected virtual guid? currenttenantid => currenttenant?.id; protected virtual bool ismultitenantfilterenabled => datafilter?.isenabled<imultitenant>() ?? false; // ... 其他的代码。 public icurrenttenant currenttenant { get; set; } // ... 其他的代码。 protected virtual expression<func<tentity, bool>> createfilterexpression<tentity>() where tentity : class { // 定义一个 lambda 表达式。 expression<func<tentity, bool>> expression = null; // 如果聚合根/实体实现了软删除接口,则构建一个软删除过滤器。 if (typeof(isoftdelete).isassignablefrom(typeof(tentity))) { expression = e => !issoftdeletefilterenabled || !ef.property<bool>(e, "isdeleted"); } // 如果聚合根/实体实现了多租户接口,则构建一个多租户过滤器。 if (typeof(imultitenant).isassignablefrom(typeof(tentity))) { // 筛选 tenantid 为 currenttenantid 的数据。 expression<func<tentity, bool>> multitenantfilter = e => !ismultitenantfilterenabled || ef.property<guid>(e, "tenantid") == currenttenantid; expression = expression == null ? multitenantfilter : combineexpressions(expression, multitenantfilter); } return expression; } // ... 其他的代码。 }
2.4.2 种子数据构建
在 volo.abp.tenantmanagement 模块当中,如果用户创建了一个租户,abp 不只是在租户表插入一条新数据而已。它还会设置种子数据的 构造上下文,并且执行所有的 种子数据构建者(idataseedcontributor
)。
[authorize(tenantmanagementpermissions.tenants.create)] public virtual async task<tenantdto> 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, tenantdto>(tenant); }
这些构建者当中,就包括租户的超级管理员(admin)和角色构建,以及针对超级管理员角色进行权限赋值操作。
这里需要注意第二点,如果开发人员没有指定超级管理员用户和密码,那么还是会使用默认密码为租户生成超级管理员,具体原因看如下代码。
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
参数。
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<abptenantmanagementresource>(name); } }
下面是权限种子数据构造者的代码:
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 则说明是租主,如果不为空则说明是具体租户。
public static multitenancysides getmultitenancyside(this icurrenttenant currenttenant) { return currenttenant.id.hasvalue ? multitenancysides.tenant : multitenancysides.host; }
2.4.4 租户的独立设置
关于这块的内容,可以参考之前的 ,abp 也为我们提供了各个租户独立的自定义参数在,这块功能是由 tenantsettingmanagementprovider
实现的,只需要在设置参数值的时候提供租户的 providername
即可。
例如:
settingmanager.setasync("wechatisopen", "true", tenantsettingvalueprovider.providername, tenantid.tostring(), false);
三、总结
其他相关文章,请参阅 。
下一篇: 如何通过nginx负载均衡跳转https
推荐阅读
-
[Abp vNext 源码分析] - 7. 权限与验证
-
[Abp vNext 源码分析] - 6. DDD 的应用层支持 (应用服务)
-
[Abp vNext 源码分析] - 19. 多租户
-
[Abp vNext 源码分析] - 13. 本地事件总线与分布式事件总线 (Rabbit MQ)
-
[Abp vNext 源码分析] - 9. 接口参数的验证
-
[Abp vNext 源码分析] - 12. 后台作业与后台工作者
-
[Abp vNext 源码分析] - 3. 依赖注入与拦截器
-
[Abp vNext 源码分析] - 20. 电子邮件与短信支持
-
[Abp vNext 源码分析] - 4. 工作单元
-
[Abp vNext 源码分析] - 8. 审计日志