ToB企服应用市场:ToB评测及商务社交产业平台

标题: .NET云原生应用实践(六):多租户初步 [打印本页]

作者: 惊雷无声    时间: 2024-11-24 21:06
标题: .NET云原生应用实践(六):多租户初步
本章目标

出于开辟进度思量,本章暂不会完全实现多租户的整套体系,而是会实现此中的一小部门:基于默认public租户的数据隔离,并在本章节中会讨论多租户的实现框架结构。在后续的系列文章章节中,我们会完成多租户的实现。
多租户(Multi-Tenancy)

如果你对多租户应用架构非常认识,可以直接跳过本章节的阅读。
云原生应用不一定需要支持多租户,但是,多租户软件即服务(SaaS)应用一定是云原生应用。从软件发布、更新以及盈利模式的角度来看,传统软件通常是通过购买答应证或订阅服务的方式向客户提供软件。发布更新通常需要用户手动下载和安装更新的软件版本,而盈利模式主要是通过一次性贩卖答应证或者定期收取订阅费用来获得收入。相比之下,基于多租户的SaaS(软件即服务)模式是将软件部署在云端,通过互联网向多个客户提供服务。在SaaS模式下,软件的发布更新是由软件供应商在云端进行,客户无需手动更新软件版本。盈利模式通常是按照订阅费用或者按照利用量来收费,客户根据实际利用情况付费。因此,基于多租户的SaaS模式相比传统软件发布更新更加便捷和实时,盈利模式更加灵活和可预测。同时,SaaS模式也更加适应云盘算和移动化期间的需求,能够更好地满意客户的个性化需求。总体上看,与传统软件相比,SaaS会有以下这些方面的优势:
与此同时,多租户SaaS应用模式也面对了一些挑战:
在上面的这些形貌中,有一些关系到SaaS应用构造结构模子的概念:
可以看到,多租户SaaS应用中,有一个紧张的特征就是数据隔离(租户隔离):差别租户之间的数据是完全隔离的(多租户数据集成共享的场景除外),有些情况下,租户数据还会利用差别的加密密钥进行加密以防止数据泄漏,确保数据安全。常见的数据隔离方式有物理隔离和逻辑隔离,物理隔离通常利用独立的服务器和数据库来存放差别租户的数据,而逻辑隔离则是利用同一个数据库,只不过通过数据库的命名空间或者Schema来达到数据隔离的目标。无论是物理隔离还是逻辑隔离,在一套情况中,只运行一套SaaS应用的部署(也就是运行的前端应用、微服务、API等只有一套)。
软件架构的魅力就在于,无论你的选择是什么,总会有利弊,所以你需要根据实际情况进行权衡。租户隔离是选择物理隔离还是逻辑隔离,也需要根据实际需求和成本、运维难度等现状来决定,两者在差别的场景下也是各有利弊。有兴趣的读者可以自行搜索查阅资料,这里就不赘述了。
回到我们的案例,Stickers采用逻辑隔离的方式,基于PostgreSQL的Schema实现数据隔离。在IdP(Identity Provider)这边借助于Keycloak的Realm Client实现租户隔离,但这有一个弊端,Keycloak中用户和用户组是基于Realm的,而不是基于Client的,因此,如果是基于Client的租户隔离,那么从Keycloak的角度,雷同的账户名就会被多个租户“可见”,并且差别的租户则不能利用雷同的账户名。比如:某个用户名为daxnet,这个账号的邮箱地址为daxnet@example.com,如果这个账户是属于租户A的,那么当B租户希望新增一个名为daxnet的账户时,就会发生“账户名称已经存在”的问题,因为daxnet账户是跨Client的(Realm级别),但实际中,应该是可以答应同一个账户名称出如今差别的租户中的。
Stickers案例中的多租户设计与实现

由于Stickers利用PostgreSQL的Schema来实现数据隔离,所以,在调用Stickers微服务所提供的API时,就需要区分当前租户是什么,以及当前用户是谁,从而才可以根据租户名称来查询相应的Schema,并获取当前登录用户的信息。而对于一个登录用户而言,租户的信息和用户的信息都是生存在IdP里的,比如,如果对Keycloak所颁发的access token进行解码,就能够获取到租户的名称:

这个租户名称也就是PostgreSQL中的Schema名称。除此之外,由于我们在Client中设置了用户组的Client Scope(usergroup),因此,在access token中也会自带用户组的信息:

请注意这个用户组的信息包含了一个字符串数组,用以表现该用户属于哪些用户组。上面已经提到,在Keycloak中,用户和用户组是Realm级别的,因此,在设计用户组时,我们将最上层的组以租户的名称命名,这样也就区分了差别租户下的用户分组。这也就是为什么对于上面这个用户账户而言,它会属于/public/users这个组。
如果你选择的IdP不是Keycloak,你也可以在IdP的实现中寻求一种合理的方式从而获得租户的名称,比如,可以将租户信息以自定义属性的形式附加在access token上。不管利用何种方式,将用户的基本信息包含在access token中,这始终是一个好的设计,这样可以减少后续微服务通过API调用来查询用户信息所带来的工作负荷,以及由缓存机制带来的复杂度。但也需要注意,不要将大量的客户化数据放在access token中,以免产生性能损耗。
当我们可以从access token中获取租户信息时,在Stickers微服务的API层面,实现起来就很简单了,只需要通过UserClaims获取此中类型为azp的Claim即可。下面的序列图表述了多租户模式下API访问的过程(Authentication flow相关的部门已省略):

起首修改数据库,在public schema下的Stickers数据表中增加一列,用来生存用户的UserId:
  1. ALTER TABLE IF EXISTS public.stickers
  2.     ADD COLUMN "UserId" character varying(128) NOT NULL;
复制代码
然后,在Sticker业务模子对象中,也增加一个属性,用来生存UserId,这个属性与数据库中的UserId对应:
  1. [StringLength(128)]
  2. public string UserId { get; set; } = string.Empty;
复制代码
第三步,修改ISimplifiedDataAccessor接口以及相应的PostgreSqlDataAccessor实现,将租户Id(TenantId)加入到数据访问层,从下面的代码可以看到,与之前的代码相比,每个方法的参数中都多了一个tenantId的参数:
  1. public interface ISimplifiedDataAccessor
  2. {
  3.     Task<int> AddAsync<TEntity>(string tenantId,
  4.         TEntity entity,
  5.         CancellationToken cancellationToken = default)
  6.         where TEntity : class, IEntity;
  7.     Task<int> RemoveByIdAsync<TEntity>(string tenantId
  8.         int id,
  9.         CancellationToken cancellationToken = default)
  10.         where TEntity : class, IEntity;
  11.     Task<TEntity?> GetByIdAsync<TEntity>(string tenantId
  12.         int id
  13.         CancellationToken cancellationToken = default)
  14.         where TEntity : class, IEntity;
  15.     Task<int> UpdateAsync<TEntity>(string tenantId
  16.         int id
  17.         TEntity entity
  18.         CancellationToken cancellationToken = default)
  19.         where TEntity : class, IEntity;
  20.     Task<Paginated<TEntity>> GetPaginatedEntitiesAsync<TEntity, TField>(
  21.         string tenantId,
  22.         Expression<Func<TEntity, TField>> orderByExpression,
  23.         bool sortAscending = true, int pageSize = 25, int pageNumber = 1,
  24.         Expression<Func<TEntity, bool>>? filterExpression = null
  25.         CancellationToken cancellationToken = default)
  26.         where TEntity : class, IEntity;
  27.     Task<bool> ExistsAsync<TEntity>(string tenantId,
  28.         Expression<Func<TEntity, bool>> filterExpression,
  29.         CancellationToken cancellationToken = default)
  30.         where TEntity : class, IEntity;
  31. }
复制代码
简单分析后不难发现,由于TenantId和UserId参数的引入,使得我们查询数据库的时间,就会需要根据当前的TenantId和UserId来过滤数据。TenantId是比较容易解决的,只需将SQL语句中的public(也就是public schema)替换为真正的TenantId就可以了,只不过目前即使替换了,SQL语句中仍然是在查询public schema。而UserId就会相对复杂一点,例如,在获取某个贴纸是否存在时,以下现有代码:
  1. var exists = await dac.ExistsAsync<Sticker>(
  2.     CurrentTenantName,
  3.     s => s.Title == title);
复制代码
就需要改为:
  1. var exists = await dac.ExistsAsync<Sticker>(
  2.     CurrentTenantName,
  3.     s => s.Title == title && s.UserId == CurrentUserName);
复制代码
也就是,需要同时判定贴纸的标题和用户Id是否重复,因为差别的用户也可以利用雷同的贴纸标题。这里就涉及Lambda表达式的处置处罚,于是,之前在PostgreSqlDataAccessor中实现的BuildSqlWhereClause方法就需要相应的重构:需要在所支持的表达式类型中增加两个类型:AndAlso和OrElse:
[code]var oper = binaryExpression.NodeType switch{    ExpressionType.Equal => "=",    ExpressionType.NotEqual => "",    ExpressionType.GreaterThan => ">",    ExpressionType.GreaterThanOrEqual => ">=",    ExpressionType.LessThan => "




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4