本章目标
- 多租户简介
- 实现public租户下的用户数据隔离
出于开发进度考虑,本章暂不会完全实现多租户的整套体系,而是会实现其中的一小部分:基于默认public租户的数据隔离,并在本章节中会讨论多租户的实现框架结构。在后续的系列文章章节中,我们会完成多租户的实现。
多租户(Multi-Tenancy)
如果你对多租户应用架构非常熟悉,可以直接跳过本章节的阅读。
云原生应用不一定需要支持多租户,但是,多租户软件即服务(SaaS)应用一定是云原生应用。从软件发布、更新以及盈利模式的角度来看,传统软件通常是通过购买许可证或订阅服务的方式向客户提供软件。发布更新通常需要用户手动下载和安装更新的软件版本,而盈利模式主要是通过一次性销售许可证或者定期收取订阅费用来获得收入。相比之下,基于多租户的SaaS(软件即服务)模式是将软件部署在云端,通过互联网向多个客户提供服务。在SaaS模式下,软件的发布更新是由软件供应商在云端进行,客户无需手动更新软件版本。盈利模式通常是按照订阅费用或者按照使用量来收费,客户根据实际使用情况付费。因此,基于多租户的SaaS模式相比传统软件发布更新更加便捷和实时,盈利模式更加灵活和可预测。同时,SaaS模式也更加适应云计算和移动化时代的需求,能够更好地满足客户的个性化需求。总体上看,与传统软件相比,SaaS会有以下这些方面的优势:
- 由于软件系统是部署在云端,因此是由软件供应商负责运维,用户不需要担心系统运维的成本和复杂度
- 由于软件供应商可以实时更新和维护软件,客户无需手动下载和安装更新,始终使用最新版本的软件,提高了安全性和效率
- 软件供应商可以根据客户使用情况来识别功能热点,进而对软件进行业务功能和技术调整
- 客户可以根据实际需求随时增减用户数量或调整服务套餐。这种灵活性和可扩展性使得SaaS更适应企业的快速发展和变化
- 用户可以通过各种设备和平台(如PC、手机、平板)随时随地访问和使用软件,提高了工作的灵活性和便捷性
- SaaS软件通常支持API集成,客户可以方便地将SaaS软件与其他系统集成,实现定制化需求。此外,SaaS提供商通常会提供一些定制化的功能和服务,帮助客户更好地满足自身需求
与此同时,多租户SaaS应用模式也面临了一些挑战:
- 将数据存储在云端可能会引发安全性和隐私问题,客户可能担心数据泄露或未经授权的访问。因此,SaaS提供商需要采取有效的安全措施来保护客户数据,并在商业合作上,与客户达成责任共担的合作模式
- SaaS软件需要稳定的互联网连接才能正常运行,如果网络连接不稳定或中断,可能会影响用户的工作效率和体验
- 尽管SaaS软件通常支持API集成和定制化功能,但某些情况下,客户特定的定制化需求可能会面临挑战,需要额外的开发工作和成本。比如有些大客户希望有自己的一套环境部署,而不仅仅是某个环境下的一个租户;在某些业务领域,软件供应商可能会提前实现某些业务功能并提供给某些客户去试用,这就产生了功能模块定制与面向特定客户开放的设计挑战
- 在SaaS模式下,客户的数据存储在提供商的云端服务器上,可能会引发数据所有权和迁移问题。如果客户决定切换到另一个SaaS提供商,可能需要面对数据迁移的复杂性和成本
- 由于SaaS软件是部署在提供商的云端服务器上,可能会受到服务器故障、维护升级等因素影响,导致服务不稳定或性能下降。这对于软件供应商来说,需要有较高水准的系统监控和问题排查能力
- 客户在选择SaaS提供商时需要谨慎,因为他们会完全依赖于提供商提供的服务和支持。如果提供商出现问题或服务中断,客户可能会受到影响
在上面的这些描述中,有一些关系到SaaS应用组织结构模型的概念:
- 客户(Customer):通常是指购买SaaS应用的个人或者组织。比如某家化工企业,购买了化工制品成分分析的SaaS应用,那么这家化工企业就是客户
- 用户(User):指某个客户下的真正使用SaaS应用的个人或者集体。比如真正操作化工制品成分分析应用软件的工作人员
- 租户(Tenant):某个客户在SaaS应用中创建的子账户或者子组织,因此,一个客户可以有一个或多个租户,各个租户之间的数据是严格隔离的
- 系统管理员:指SaaS应用的管理员,一般由软件供应商方面承担这一角色,该管理员角色具有创建租户、管理订阅等权限
- 租户管理员:指负责管理某个租户的个人或组织,一般由客户内部的员工或团队承担这一角色
- 环境:一套环境运行了一个SaaS应用实例,它是一整套SaaS应用服务的独立部署,大家熟知的比如QA环境和生产环境就是两套不同的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
:
ALTER TABLE IF EXISTS public.stickers ADD COLUMN "UserId" character varying(128) NOT NULL;
然后,在Sticker
业务模型对象中,也增加一个属性,用来保存UserId
,这个属性与数据库中的UserId
对应:
[StringLength(128)] public string UserId { get; set; } = string.Empty;
第三步,修改ISimplifiedDataAccessor
接口以及相应的PostgreSqlDataAccessor
实现,将租户Id(TenantId)加入到数据访问层,从下面的代码可以看到,与之前的代码相比,每个方法的参数中都多了一个tenantId
的参数:
public interface ISimplifiedDataAccessor { Task<int> AddAsync<TEntity>(string tenantId, TEntity entity, CancellationToken cancellationToken = default) where TEntity : class, IEntity; Task<int> RemoveByIdAsync<TEntity>(string tenantId int id, CancellationToken cancellationToken = default) where TEntity : class, IEntity; Task<TEntity?> GetByIdAsync<TEntity>(string tenantId int id CancellationToken cancellationToken = default) where TEntity : class, IEntity; Task<int> UpdateAsync<TEntity>(string tenantId int id TEntity entity CancellationToken cancellationToken = default) where TEntity : class, IEntity; Task<Paginated<TEntity>> GetPaginatedEntitiesAsync<TEntity, TField>( string tenantId, Expression<Func<TEntity, TField>> orderByExpression, bool sortAscending = true, int pageSize = 25, int pageNumber = 1, Expression<Func<TEntity, bool>>? filterExpression = null CancellationToken cancellationToken = default) where TEntity : class, IEntity; Task<bool> ExistsAsync<TEntity>(string tenantId, Expression<Func<TEntity, bool>> filterExpression, CancellationToken cancellationToken = default) where TEntity : class, IEntity; }
简单分析后不难发现,由于TenantId和UserId参数的引入,使得我们查询数据库的时候,就会需要根据当前的TenantId和UserId来过滤数据。TenantId是比较容易解决的,只需将SQL语句中的public(也就是public schema)替换为真正的TenantId就可以了,只不过目前即使替换了,SQL语句中仍然是在查询public schema。而UserId就会相对复杂一点,例如,在获取某个贴纸是否存在时,以下现有代码:
var exists = await dac.ExistsAsync<Sticker>( CurrentTenantName, s => s.Title == title);
就需要改为:
var exists = await dac.ExistsAsync<Sticker>( CurrentTenantName, s => s.Title == title && s.UserId == CurrentUserName);
也就是,需要同时判断贴纸的标题和用户Id是否重复,因为不同的用户也可以使用相同的贴纸标题。这里就涉及Lambda表达式的处理,于是,之前在PostgreSqlDataAccessor
中实现的BuildSqlWhereClause
方法就需要相应的重构:需要在所支持的表达式类型中增加两个类型:AndAlso
和OrElse
:
var oper = binaryExpression.NodeType switch { ExpressionType.Equal => "=", ExpressionType.NotEqual => "<>", ExpressionType.GreaterThan => ">", ExpressionType.GreaterThanOrEqual => ">=", ExpressionType.LessThan => "<", ExpressionType.LessThanOrEqual => "<=", ExpressionType.AndAlso => " AND ", ExpressionType.OrElse => " OR ", _ => null };
并对这两种新增的表达式类型进行相应的处理,也就是针对AndAlso
和OrElse
的左右两边运算符分别递归调用BuildSqlWhereClause
方法,并将获得的SQL语句拼接起来:
if (string.Equals(oper, " AND ") || string.Equals(oper, " OR ")) { var leftStr = BuildSqlWhereClause(binaryExpression.Left); var rightStr = BuildSqlWhereClause(binaryExpression.Right); return string.Concat(leftStr, oper, rightStr); }
最后,就是在StickersController
上,通过当前登录用户的access token中的azp
和preferred_username
这两个Claim来获得登录用户所在的租户Id和用户Id:
private string CurrentTenantName { get { if (User.Identity is ClaimsIdentity { IsAuthenticated: true } identity) return identity.Claims.FirstOrDefault(c => c.Type == "azp")?.Value ?? throw new AuthenticationException( "Get current tenant name failed: Claim "azp" doesn't exist on the user identity."); throw new AuthenticationException("Can't get the current tenant name: User is not authenticated."); } } private string CurrentUserName { get { if (User.Identity is ClaimsIdentity { IsAuthenticated: true } identity) return identity.Claims.FirstOrDefault(c => c.Type == (configuration["keycloak:nameClaimType"] ?? "preferred_username"))?.Value ?? throw new AuthenticationException( "Get current user name failed: Claim "preferred_username" doesn't exist on the user identity."); throw new AuthenticationException("Can't get the current user name: User is not authenticated."); } }
因此,在调用Stickers API的时候,只要是已经登录的认证用户,API就会自动获得TenantId和UserId,不需要客户端调用方进行指定。
实现效果
启动整个应用程序,以daxnet
用户登录,所看到的贴纸如下所示:
退出登录,然后以super
用户登录,所看到的贴纸如下所示:
总结
本文虽然没有完整实现多租户的整个流程,但已经从public这个特殊的租户层面,对用户数据进行了隔离,也就是修复了在前一篇文章中最后所提到的那个Bug。基本上从API层面,已经有了支持多租户的技术基础,更进一步的实现就需要配合反向代理和域名解析,以及Blazor WebAssembly对OIDC动态ClientID的支持等这些技术细节来共同完成。目前我们的案例已经基本完成单个租户所能支持的主体功能,接下来我们会要开始探索容器化、持续集成与持续部署等等与云原生相关的话题,之后在完成这些主题的讨论研究后,回过头来再来解决多租户问题,同时还会考虑扩展现有的业务功能。下一讲将介绍Stickers应用程序的容器化,以及如何在本地docker compose中运行一整套应用。
源代码
【本章源代码已更新到最新的 .NET 9】本章源代码在chapter_6这个分支中:https://gitee.com/daxnet/stickers/tree/chapter_6/
下载源代码前,请先删除已有的stickers-pgsql:dev
和stickers-keycloak:dev
两个容器镜像,并删除docker_stickers_postgres_data
数据卷。
下载源代码后,进入docker目录,然后编译并启动容器:
$ docker compose -f docker-compose.dev.yaml build $ docker compose -f docker-compose.dev.yaml up
现在就可以直接用Visual Studio 2022或者JetBrains Rider打开stickers.sln解决方案文件,然后同时启动Stickers.WebApi
和Stickers.Web
两个项目进行调试运行了。