
我不会给出多租户的定义,他们已经在这里和这里多次写过这个书。 最好直接进入本文的主题,并从以下问题入手:
为什么应用程序不立即成为多租户?
碰巧该应用程序最初只是为在客户端安装而开发的。 您可以将此类应用程序称为盒装产品,也可以将其称为产品 。 客户购买一个盒子,然后在其服务器上部署该应用程序(此类应用程序有很多示例)。
但是随着时间的流逝,开发公司可能会认为将应用程序放置在云中以便出租(软件即服务)会很好。 这种部署方法对客户和开发公司都有好处。 客户可以快速获得工作系统,而不必担心部署和管理。 租用应用程序时,您不需要大量的一次性投资。
开发者公司将收到新客户以及新任务:在云中部署应用程序,管理,更新到新版本,在更新过程中迁移数据,数据备份,监视速度和错误,并在出现问题时进行修复。
为什么云中的应用程序应为多租户?
要将应用程序放置在云中,无需使其成为多租户。 但是,这将带来以下问题:对于每个客户端,都有必要使用租用的应用程序在云中部署专用的机架,这在云机架的资源消耗和管理方面都已经非常昂贵。 在应用程序中实现多租户是更有利可图的,这样一个实例可以为多个客户(组织)提供服务。
如果应用程序吸引了1000个同时工作的用户,则对客户端(组织)进行分组是有利的,这样,每个应用程序实例总共可以为1000个用户提供所需的负载。 然后,将有最优化的云资源消耗。
假定该应用程序是由一个组织租用的,供20个用户(该组织的雇员)使用。 然后,您需要将这些组织中的50个分组以达到正确的负载。 将组织彼此隔离很重要。 一个组织租用一个应用程序,只允许其员工去那里,仅存储其数据,并且看不到其他组织也由同一应用程序提供服务。
实施多租户并不意味着该应用程序无法再在组织的服务器上本地部署。 您可以同时支持两种部署方法:
- 云中的多租户应用程序;
- 客户端服务器上的单租户应用程序。
我们的应用程序采用了类似的方式:从非租户到多租户。 在本文中,我将分享一些开发多租户的方法。
如何在设计为非租户的应用程序中实现多租户?
我们将立即限制该主题,我们将仅考虑开发,而不会涉及测试,版本发布,部署,管理等问题。 在所有这些领域中,也应考虑到多租户的出现,但是现在我们仅讨论发展。
为了理解什么不是租户而是变成多租户的应用程序,我将描述其目的,并列出所使用的服务和技术。
这是一个ECM系统(DirectumRX),由10个服务(5个单片服务和5个微服务)组成。 所有这些服务都可以放在一台功能强大的服务器上,也可以放在多台服务器上。
服务是- Web服务-用于服务Web客户端(浏览器)。
- WCF服务-用于服务桌面客户端(WPF应用程序)。
- 移动应用程序服务。
- 用于执行后台进程的服务。
- 用于计划后台流程的服务。
- 工作流程方案执行服务
- 工作流程块执行服务
- 文档存储服务(二进制数据)。
- 用于将文档转换为html的服务(在浏览器中预览)。
- 用于将转换结果存储在html中的服务
使用的技术堆栈:
.NET + SQLServer / Postgres + NHibernate + IIS + RabbitMQ + Redis
那么,如何使服务成为多租户呢? 为此,您需要完善服务中的以下机制,即,将有关租户的知识添加到:
- 数据存储;
- ORM;
- 数据缓存;
- 请求处理;
- 处理队列消息;
- 配置
- 记录
- 执行后台任务;
- 与微服务的交互;
- 与消息代理的交互。
在我们的应用程序中,这些是需要改进的主要地方。 让我们分别考虑它们。
选择数据存储方式
当您阅读有关多租户的文章时,他们整理的第一件事就是如何组织数据存储。 确实,这一点很重要。
对于我们的ECM系统,主存储是一个关系数据库,其中包含约100个表。 如何组织许多组织的数据存储,以使组织A不会看到组织B的数据?
已知几种方案(关于这些方案,已经有很多著作):
- 为每个组织(为每个租户)创建自己的数据库;
- 所有组织都使用一个数据库,但每个组织都在数据库中制定自己的方案;
- 所有组织使用一个数据库,但在每个表中添加一列“承租人/组织密钥”。
选择方案并非偶然。 在我们的案例中,考虑系统管理的案例以了解首选方案就足够了。 情况如下:
- 增加租户(一个新的组织租用系统);
- 移走租户(该组织拒绝租用);
- 将租户转移到另一个云站(当一个站停止应付负载时,在云站之间重新分配负载)。
考虑一个租户转移案例。 传输的主要任务是将组织的数据传输到另一个站点。 如果承租人拥有自己的数据库,转移并不难,但是如果将不同组织的数据混合在100个表中,将很头疼。 尝试仅从表中提取必要的数据,将它们传输到另一个数据库,该数据库中已经有来自其他租户的数据,因此它们的标识符不会相交。
下一种情况是添加新的租户。 情况也不简单。 添加租户需要填写系统目录,用户,权限,以便您完全可以登录系统。 克隆参考数据库已经可以满足您的所有需求,从而最好地解决了该任务。
通过禁用租户数据库,很容易解决租户删除案例。
由于这些原因,我们选择了一个方案: 一个租户一个数据库 。
ORM
我们选择了数据存储方法,接下来的问题是:如何教ORM使用所选的方案?
我们使用Nhibernate。 要求Nhibernate处理多个数据库,并根据http请求定期切换到正确的数据库。 如果我们处理组织A的请求,则使用数据库A,如果请求来自组织B,则使用数据库B。
NHibernate有这样的机会。 您需要重写NHibernate.Connection.DriverConnectionProvider的实现。 每当NHibernate要打开数据库连接时,它都会调用DriverConnectionProvider以获取连接字符串。 在这里,我们将用必要的替换它:
public class MyDriverConnectionProvider : DriverConnectionProvider { protected override string ConnectionString => TenantRegistry.Instance.CurrentTenant.ConnectionString; }
什么是TenantRegistry.Instance.CurrentTenant我待会再讲 。
资料快取
服务通常会缓存数据,以最大程度地减少数据库查询或不多次计算同一件事。 问题是,如果缓存租户数据,则必须由租户分解缓存。 处理另一个组织的请求时,不能使用一个组织的数据缓存。 最简单的解决方案是将租户标识符添加到每个缓存的键:
var tenantCacheKey = cacheKey + TenantRegistry.Instance.CurrentTenant.Id;
创建每个缓存时必须记住此问题。 我们的服务中有很多缓存。 为了避免忘记每个租户标识符,最好统一使用缓存。 例如,建立一种通用的缓存机制,该机制将在租户的上下文中开箱即用地缓存。
记录中
迟早,系统中会出现问题,您需要打开日志文件并开始研究它。 第一个问题是:这些动作是代表哪个用户和哪个组织执行的?
在日志的每一行中都有一个租户标识符和一个租户用户名时,这很方便。 此信息与消息时间一样必要,例如:
2019-05-24 17:05:27.985 <message> [User2 :Tenant1] 2019-05-24 17:05:28.126 <message> [User3 :Tenant2] 2019-05-24 17:05:28.173 <message> [User4 :Tenant3]
开发人员不应考虑将哪个租户写入日志,而应将其自动化,隐藏在日志系统的“幕后”。
我们使用NLog,因此我将举一个例子。 保护租户标识符的最简单方法是创建NLog.LayoutRenderers.LayoutRenderer ,这将允许您获取每个日志条目的租户标识符:
[LayoutRenderer("tenant")] public class TenantLayoutRenderer : LayoutRenderer { protected override void Append(StringBuilder builder, LogEventInfo logEvent) { builder.Append(TenantRegistry.Instance.CurrentTenant.Id); } }
然后在日志模板中使用此LayoutRenderer:
<target layout="${odate} ${message} [${user} :${tenant}]"/>
代码执行
在上面的示例中,我经常使用以下代码:
TenantRegistry.Instance.CurrentTenant
现在该说出这是什么意思了。 但是首先,您需要了解我们在服务中遵循的方法:
任何代码执行(处理http请求,处理队列消息,在单独的线程中执行后台任务)都必须与某个租户关联。
这意味着在代码执行的任何地方都可能会问:“此线程对哪个租户起作用?” 或以另一种方式,“当前租户是什么?”
TenantRegistry.Instance.CurrentTenant是当前流的当前租户。 流和租户可以在我们的应用程序中链接。 它们是临时连接的,例如在处理http请求或处理队列中的消息时。 将租户绑定到流的一种方法是这样完成的:
通过联系TenantRegistry ,可以在代码中的任何位置获取绑定到线程的租户-这是一个单例,是与租户合作的访问点。 因此,Nhibernate和NLog可以访问此单例(在扩展点)以查找连接字符串或租户标识符。
后台任务
服务通常具有需要在计时器上执行的后台任务。 后台任务可以访问组织的数据库,然后必须为每个租户执行后台任务。 为此,不必为每个租户启动单独的计时器或线程。 可以在单个线程/计时器内的不同租户中执行任务。 为此,在计时器处理程序中,我们对租户进行排序,将每个租户与流相关联并执行后台任务:
不能同时将两个租户附加到流;如果我们附加一个,则另一个将与流分离。 我们积极使用这种方法,以免为后台任务生成线程/计时器。
如何将http请求与租户相关联
要处理客户的http请求,您需要知道他来自哪个组织。 如果用户已经通过身份验证,则可以将租户标识符存储在身份验证cookie(如果通过浏览器执行与应用程序的工作)或JWT令牌中。 但是,如果用户尚未认证怎么办? 例如,一个匿名用户打开了一个应用程序网站并想要进行身份验证。 为此,他发送了一个带有登录名和密码的请求。 在哪个组织的数据库中寻找该用户?
同样,将收到匿名请求以将登录页面获取到应用程序,并且它对于不同的组织可能有所不同,例如本地化语言。
为了解决匿名http请求和组织(承租人)之间的相关性问题,我们为组织使用子域。 子域的名称由组织名称组成。 用户必须使用子域才能与系统一起使用:
https://company1.service.com https://company2.service.com
在这些地址上可以使用相同的多租户Web服务。 但是现在该服务可以了解匿名http请求来自哪个组织,重点是域名。
域名和租户的绑定在Web服务配置文件中执行:
<tenant name="company1" db="database1" host="company1.service.com" /> <tenant name="company2" db="database2" host="company2.service.com" />
关于配置服务的说明如下。
微服务。 资料储存
当我说ECM系统需要100个表时,我谈到了整体服务。 但是碰巧的是,微服务需要关系存储,其中需要2-3个表来存储其数据。 理想情况下,每个微服务都有自己的存储,只有它可以访问。 微服务决定如何在租户的上下文中存储数据。
但是我们走了另一条路:我们决定将组织的所有数据存储在一个数据库中。 如果微服务需要关系存储,那么它将使用现有的组织数据库,以便数据不会分散在不同的存储中,而是收集在一个数据库中。 整体服务使用相同的数据库。
微服务仅与数据库中的表一起使用,而不尝试与整体或其他微服务的表一起使用。 这种方法有利有弊。
优点:
- 组织数据放在一个地方;
- 易于备份和还原组织数据;
- 在备份中,所有服务的数据都是一致的。
缺点:
- 扩展时,所有服务的一个数据库是一条狭窄的脖子(对DBMS资源的需求增加);
- 微服务可以物理访问彼此的表,但是不使用此功能。
微服务。 并不总是需要租户的知识。
微服务可能不知道它可以在多租户环境中工作。 考虑我们的一项服务,该服务用于将文档转换为html。
服务的作用:
- 从RabbitMQ队列获取消息以转换文档。
- 从文档存储服务下载文档。
- 为此,生成一个请求,在该请求中传输文档标识符和租户标识符
- 将文档转换为html。
- 将html提供给服务以存储转换结果。
该服务不存储文档,也不存储转换结果。 他对租户有间接的了解:租户标识符在传输过程中通过服务。
微服务。 不需要子域
我在上面写道,子域有助于解决匿名http请求的问题:
https://company1.service.com https://company2.service.com
但是,并非所有服务都可以处理匿名请求,大多数服务都需要已通过身份验证。 因此,通过http运行的微服务通常不在乎请求来自哪个主机名,它们会从每个请求随附的JWT令牌或身份验证cookie中接收有关租户的所有信息。
构型
需要配置服务,以便他们了解租户。 即:
- 指定用于连接到租户数据库的字符串;
- 将域名绑定到租户;
- 指定租户的默认语言和时区。
租户可以进行许多设置。 对于我们的服务,我们在xml配置文件中设置租户设置。 这不是web.config,也不是app.config。 这是一个单独的xml文件,其更改必须能够在不重新启动服务的情况下捕获,以便添加新的租户不会重新启动整个系统。
设置列表如下所示:
<block name="TENANTS"> <tenant name="Jupiter" db="DirectumRX_Jupiter" login="admin" password="password" hyperlinkUriScheme="jupiter" hyperlinkFileExtension=".jupiter" hyperlinkServer="http://jupiter-rx.directum.ru/Sungero" helpAddress="http://jupiter-rx.directum.ru/Sungero/help" devHelpAddress="http://jupiter-rx.directum.ru/Sungero/dev_help" language="Ru-ru" isAttributesSignatureAbsenceAllowed="false" endorsingSignatureLocksSignedProperties="false" administratorEmail ="admin@jupiter-company.ru" feedbackEmail="support@jupiter-company.ru" isSendFeedbackAllowed="true" serviceUserPassword="password" utcOffset="5" collaborativeEditingEnabled="false" collaborativeEditingForced="false" /> <tenant name="Mars" db="DirectumRX_Mars" login="admin" password="password" hyperlinkUriScheme="mars" hyperlinkFileExtension=".mars" hyperlinkServer="http://mars-rx.directum.ru/Sungero" helpAddress="http://mars-rx.directum.ru/Sungero/help" devHelpAddress="http://mars-rx.directum.ru/Sungero/dev_help" language="Ru-ru" isAttributesSignatureAbsenceAllowed="false" endorsingSignatureLocksSignedProperties="false" administratorEmail ="root@mars-ooo.ru" feedbackEmail="support@mars-ooo.ru" isSendFeedbackAllowed="true" serviceUserPassword="password" utcOffset="-1" collaborativeEditingEnabled="false" collaborativeEditingForced="false" /> </block>
新组织租用服务时,需要为其配置文件中添加新的租户。 希望其他组织不要有这种感觉。 理想情况下,不应重新启动服务。
在我们这里,并非所有服务都能够在不重新启动的情况下获取配置,但是最关键的服务(整体)能够做到这一点。
总结
当应用程序成为多租户时,开发的复杂性似乎急剧增加。 但是随后您习惯了多租户,并将其支持视为正常需求。
还值得记住的是,多租户不仅是开发,而且是测试,管理,部署,更新,备份,数据迁移。 但是下次再说。