本章目标

  • 多租户简介
  • 实现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方法就需要相应的重构:需要在所支持的表达式类型中增加两个类型:AndAlsoOrElse

var oper = binaryExpression.NodeType switch
{
ExpressionType.Equal => "=",
ExpressionType.NotEqual => "<>",
ExpressionType.GreaterThan => ">",
ExpressionType.GreaterThanOrEqual => ">=",
ExpressionType.LessThan => "<",
ExpressionType.LessThanOrEqual => "<=",
ExpressionType.AndAlso => " AND ",
ExpressionType.OrElse => " OR ",
_ => null
};

并对这两种新增的表达式类型进行相应的处理,也就是针对AndAlsoOrElse的左右两边运算符分别递归调用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中的azppreferred_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:devstickers-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.WebApiStickers.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. animate动画库的使用

    在vue中便捷使用animate动画库效果. 安装animate动画库 npm install animate.css --save 在vue跟目录中 main.js 导入animate动画库 imp ...

  2. LaViT:这也行,微软提出直接用上一层的注意力权重生成当前层的注意力权重 | CVPR 2024

    Less-Attention Vision Transformer利用了在多头自注意力(MHSA)块中计算的依赖关系,通过重复使用先前MSA块的注意力来绕过注意力计算,还额外增加了一个简单的保持对角性 ...

  3. 【VMware VCF】VCF 5.2:配置管理域 vSAN 延伸集群。

    VMware vSAN 解决方案中,根据集群的配置类型分为 vSAN 标准集群.vSAN 延伸集群以及双主机集群(延伸集群特例).我们最常见的使用方式应该是 vSAN 标准集群,也就是 vSAN HC ...

  4. 【笔记】理解CSS3之Flexbox布局

    前言 先看一个例子,html代码如下: <ul> <li>1</li> <li>2</li> <li>3</li> ...

  5. postgre基于行数的外连接及python连接postgre数据库

    外连接 左外/右外连接 左外连接:左表全部出现在结果集中,若右表无对应记录,则相应字段为NULL left join ... on 条件 右外连接:右表全部出现在结果集中,若左表无对应记录,则相应字段 ...

  6. 精彩回顾|【ACDU 中国行·杭州站】数据库主题交流活动成功举办!

    8月19日下午,[ACDU 中国行·杭州站]在杭州西溪万怡酒店圆满落下帷幕.本次活动由中国数据库联盟(ACDU)联合墨天轮社区主办,蚂蚁集团 OceanBase 及亚信科技 AntDB 赞助支持.六位 ...

  7. 数组 Array 的属性 和 方法总结

    1. Array 的属性 2. Array 的方法 2.1 增加数组单元 参数一半都是数组单元 a)unshift 方法 在数组的最前面添加数组元素 <script> const arr ...

  8. iotdb时序数据库常见使用命令

    docker 安装IOTDB核心代码: #docker启动 docker run -d -p 6667:6667 -p 31999:31999 -p 8181:8181 --name some-iot ...

  9. vant2 List 组件 下拉加载 onLoad

    ps:loading finished onLoad 两个变量一个函数 : async onLoad() { console.log("onload"); // 异步更新数据 // ...

  10. 13. 说一下$set,用在Vue2还是Vue3

    $set 是 vue2 中对象用来追加响应式数据的方法 : 使用格式 : $set(对象 , 属性名 , 值 ) vue3中使用 proxy 替代了 Object.defineProperty 实现对 ...