我们提供安全,免费的手游软件下载!
出于开发进度考虑,本章暂不会完全实现多租户的整套体系,而是会实现其中的一小部分:基于默认public租户的数据隔离,并在本章节中会讨论多租户的实现框架结构。在后续的系列文章章节中,我们会完成多租户的实现。
如果你对多租户应用架构非常熟悉,可以直接跳过本章节的阅读。
云原生应用不一定需要支持多租户,但是,多租户软件即服务(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使用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 AddAsync(string tenantId,
TEntity entity,
CancellationToken cancellationToken = default)
where TEntity : class, IEntity;
Task RemoveByIdAsync(string tenantId
int id,
CancellationToken cancellationToken = default)
where TEntity : class, IEntity;
Task GetByIdAsync(string tenantId
int id
CancellationToken cancellationToken = default)
where TEntity : class, IEntity;
Task UpdateAsync(string tenantId
int id
TEntity entity
CancellationToken cancellationToken = default)
where TEntity : class, IEntity;
Task> GetPaginatedEntitiesAsync(
string tenantId,
Expression> orderByExpression,
bool sortAscending = true, int pageSize = 25, int pageNumber = 1,
Expression>? filterExpression = null
CancellationToken cancellationToken = default)
where TEntity : class, IEntity;
Task ExistsAsync(string tenantId,
Expression> 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(
CurrentTenantName,
s => s.Title == title);
就需要改为:
var exists = await dac.ExistsAsync(
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
两个项目进行调试运行了。
热门资讯