本章目标

  • 多租户简介
  • 实现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中运行一整套应用。

源代码

本章源代码在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两个项目进行调试运行了。

.NET云原生应用实践(六):多租户初步的更多相关文章

  1. “行业客户云原生最佳实践日” 亮相KubeCon上海

    2018年11月13日至15日,由CNCF主办的KubeCon + CloudNativeCon将首次登陆中国上海,这是全球范围内规模最大的Kubernetes和云原生技术盛会. 唯一聚焦客户实践的分 ...

  2. ​第3届云原生技术实践峰会(CNBPS 2020)重磅开启,“原”力蓄势待发!

    CNBPS 2020将在11月19-21日全新启动!作为国内最有影响力的云原生盛会之一,云原生技术实践峰会(CNBPS)至今已举办三届. 在2019年的CNBPS上,灵雀云CTO陈恺喊出"云 ...

  3. Sentry 后端云原生中间件实践 ClickHouse PaaS ,为 Snuba 事件分析引擎提供动力

    目录(脑图) ClickHouse PaaS 云原生多租户平台(Altinity.Cloud) 官网:https://altinity.cloud PaaS 架构概览 设计一个拥有云原生编排能力.支持 ...

  4. 阿里云 CDN 业务基于边缘容器的云原生转型实践

    导读:本文基于边缘容器的阿里云 CDN 云原生实践, 涵盖了边缘容器的背景和趋势,边缘托管集群 ACK Managed Edge K8s(文中简称“Edge@ACK”) 的能力.架构,以及基于边缘容器 ...

  5. 云原生项目实践DevOps(GitOps)+K8S+BPF+SRE,从0到1使用Golang开发生产级麻将游戏服务器—第1篇

    项目初探 项目地址: 原项目:https://github.com/lonng/nanoserver 调过的:https://github.com/Kirk-Wang/nanoserver 这将是一个 ...

  6. 敢为人先,从阿里巴巴云原生团队实践Dapr案例,看分布式应用运行时前景

    背景 Dapr是一个由微软主导的云原生开源项目,国内云计算巨头阿里云也积极参与其中,2019年10月首次发布,到今年2月正式发布V1.0版本.在不到一年半的时间内,github star数达到了1.2 ...

  7. 云原生时代之Kubernetes容器编排初步探索及部署、使用实战-v1.22

    概述 **本人博客网站 **IT小神 www.itxiaoshen.com Kubernetes官网地址 https://kubernetes.io Kubernetes GitHub源码地址 htt ...

  8. 宙斯盾 DDoS 防护系统“降本增效”的云原生实践

    作者 tomdu,腾讯云高级工程师,主要负责宙斯盾安全防护系统管控中心架构设计和后台开发工作. 导语 宙斯盾 DDoS 防护系统作为公司级网络安全产品,为各类业务提供专业可靠的 DDoS/CC 攻击防 ...

  9. Aggregated APIServer 构建云原生应用最佳实践

    作者 张鹏,腾讯云容器产品工程师,拥有多年云原生项目开发落地经验.目前主要负责腾讯云 TKE 云原生 AI 产品的开发工作. 谢远东,腾讯高级工程师,Kubeflow Member.Fluid(CNC ...

  10. EKS助力小白实践云原生——通过k8s部署wordpress应用

    目前云原生在大厂已经有了充分的实践,也逐渐向小厂以及非互联网公司推广.适逢12月20日,腾讯云原生[燎原社]精心打造了云原生在线技术工坊,让零基础的同学也能快速入门和实践 Docker 和 Kuber ...

随机推荐

  1. GPG 用法

    GPG (GnuPG) 是一种加密工具,用于数据加密和数字签名. 密钥配置 # 生成密钥 gpg --full-generate-key # 列出密钥 gpg --list-keys # 列出公钥 g ...

  2. C语言指针的作业

    指针的作业 求Sn = a + aa + aaa + aaaa + aaaaa的前五项和,其中a是一个数字. 例如:2 + 22 + 222 + 2222 + 22222 /* 求Sn = a + a ...

  3. win10自动更新导致的任务栏假死,右键单击任务栏延迟菜单延迟出现

    系统是win10企业版,前天晚上回家之前,电脑还是好好的,然后前天早上跑过来以后,发现电脑自动重启了,我还以为是办公室停电了之类的(现在想了一下愚蠢,停电不会自动开机),没想到是自动更新 然后我发现, ...

  4. Consul初探-从安装到运行 【转】

    目录 前言 下载二进制包 入门必学必记文档 启动 Consul 前言 伟人说过:实践是检验真理的唯一标准!经过上一篇的学习,我基本掌握了 Consul 的基本原理,接下来就是动手实践了:Consul ...

  5. Falcon Mamba: 首个高效的无注意力机制 7B 模型

    Falcon Mamba 是由阿布扎比的 Technology Innovation Institute (TII) 开发并基于 TII Falcon Mamba 7B License 1.0 的开放 ...

  6. (零) React Native 项目开发拾遗

    一位离职的前端同事,最近接了个 React Native 的活儿,遇到许多搞不定的问题,于是找到我帮忙"补课"(没有系统的学习 React Native,也不具备原生 Androi ...

  7. 【赵渝强老师】Kafka的消息持久化

    1.Kafka消息持久性概述 Kakfa依赖文件系统来存储和缓存消息.对于硬盘的传统观念是硬盘总是很慢,基于文件系统的架构能否提供优异的性能?实际上硬盘的快慢完全取决于使用方式.同时 Kafka 基于 ...

  8. 【赵渝强老师】Redis的慢查询日志

    Redis慢查询日志帮助开发和运维人员定位系统存在的慢操作.慢查询日志就是系统在命令执行前后计算每条命令的执行时间,当超过预设阀值,就将这条命令的相关信息(慢查询ID,发生时间戳,耗时,命令的详细信息 ...

  9. 使用composer创建项目时报错:Composer could not find the config file?

    使用composer创建项目时报错:Composer could not find the config file:C:\Composer ....? 一般报这个错就是composer安装的时候配置了 ...

  10. USB gadget configfs

    概述 USB Linux Gadget是一种具有UDC (USB设备控制器)的设备,可以连接到USB主机,以扩展其附加功能,如串口或大容量存储能力. 一个gadget被它的主机视为一组配置,每个配置都 ...