XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

多租户 ​

本章讲框架怎样在一次调用里确定租户、租户存在哪里、租户数据怎样隔离到各自的库,适用于做 SaaS 或需要按租户隔离数据的应用。完整 API 见 MultiTenancy.Abstractions、MultiTenancy.InMemory 与 MultiTenancy.Database 三个包页。

租户在调用里的位置 ​

租户是调用上下文的一部分。管道的 Tenant 阶段(TenantResolutionMiddleware)在每次调用里解析一次租户、核对后写进安全上下文 SecurityContext.Tenant,之后的阶段只读它:Authorize 阶段按租户取授予,Transaction 阶段用租户开工作单元,幂等键与审计记录带上租户。框架没有环境态的"当前租户"访问器,读租户只有一个入口:context.GetSecurity().Tenant。

阶段顺序决定了租户能用到什么:Authenticate → Tenant → Authorize。Tenant 阶段在认证之后,所以能读已认证主体的租户声明;在授权之前,所以租户专属的授予在判权时已经生效。

能力分两个:multitenancy.context@1 负责解析、校验与存储,multitenancy.sharding@1 负责按租户选数据连接(分库)。两个能力都由同一个多租户 Provider 提供。

包清单角色租户存在哪里
XiHan.Framework.MultiTenancy.InMemorydefault启动时从配置 XiHan:MultiTenancy:Tenants 装载,运行时可增删,有容量上限
XiHan.Framework.MultiTenancy.Databaseprovider数据库表(TenantRecord),经装配的持久化 Provider 读写

组合包 Profiles.Standard 与 Profiles.Full 已包含 MultiTenancy.InMemory;再 UseFeature<DatabaseMultiTenancyFeature>() 即由数据库目录顶替。

租户信息 ​

一个租户是一条 TenantInfo:

成员类型说明
Idstring租户 ID,赋值时按文法校验
DisplayNamestring?显示名,解析后随租户进入安全上下文
IsEnabledbool缺省 true;禁用的租户在 Tenant 阶段被拒绝
PropertiesIReadOnlyDictionary<string, string>租户专属属性(连接串、配额等),键区分大小写

租户 ID 的文法是 ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$:字母或数字开头,之后是字母、数字、.、_、-,最长 64 个字符(TenantIdentifier.MaxLength),区分大小写。TenantIdentifier.IsValid 判断是否合法,TenantIdentifier.Validate 不合法时抛出 ArgumentException。

存储契约 ITenantStore 只有两个方法:FindAsync 按 ID 查找(不存在为无值)与 ListAsync 枚举。存储连不上这类基础设施故障以异常抛出,不伪装成"租户不存在",Tenant 阶段据此让调用失败。

启用 ​

内存实现从配置装载租户。下面是 framework/samples/SimpleApp 的配置:acme 启用,globex 已禁用。

json
{
  "XiHan": {
    "MultiTenancy": {
      "Tenants": {
        "acme": { "DisplayName": "Acme Corp" },
        "globex": { "DisplayName": "Globex", "IsEnabled": false }
      }
    }
  }
}

每个子节的节名就是租户 ID,IsEnabled 缺省视为启用,Properties 下的键值进 TenantInfo.Properties。

csharp
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// Standard 组合包已含内存多租户与内存安全
builder.AddXiHan(xihan => xihan.UseStandardProfile());

var app = builder.Build();
app.UseXiHan();

// 端点在管道的 Handle 阶段内执行,租户已由 Tenant 阶段写进安全上下文
app.MapGet("/tenant", (HttpContext http) => http.GetXiHanContext().ValueOrThrow().GetSecurity().Tenant?.Id ?? "-");

app.Run();

操作契约的处理器里直接用传进来的调用上下文:context.GetSecurity().Tenant。TenantContext 有 Id 与 DisplayName 两个成员;没有租户的调用为 null。

解析流程 ​

Tenant 阶段按下面的顺序处理,任一步失败即以结果结束调用,后面的阶段不再执行:

  1. 按注册顺序调用全部 ITenantResolver,第一个给出值的生效。
  2. 都没有值时用 DefaultTenant;仍没有时,RequireTenant 为真返回 MULTITENANCY_VALIDATION_002,否则以无租户继续。
  3. 校验租户 ID 文法,不合法返回 MULTITENANCY_VALIDATION_001。先校验再查存储,未经校验的外部输入不会交给 Provider。
  4. ValidateAgainstStore 为真(缺省)时查 ITenantStore:不存在返回 MULTITENANCY_NOTFOUND_001,已禁用返回 MULTITENANCY_DENIED_001;存在且启用时以存储里的记录为准,显示名随之带入。关闭存储核对时只校验文法,安全上下文里没有显示名。
  5. 写入 SecurityContext.Tenant。
  6. 容器里有 ITenantShardResolver 时解析分片,写进调用上下文(见租户分片)。

多租户 Provider 注册两个解析器,注册顺序就是优先级:

顺序解析器读什么
1ClaimTenantResolver已认证主体的租户声明(类型由 TenantClaim 决定,缺省 tenant),同类声明有多个时取第一个;未认证主体直接跳过
2BaggageTenantResolver调用上下文的 Baggage xihan.tenant,由进程内建上下文的地方放进去

应用登记自己的 ITenantResolver 会排在这两个之后。解析器只负责给出候选 ID,校验都在中间件里。

Baggage 里的租户只来自进程内:消息派发时取信封的扩展属性 xihantenant,作业与工作流带上发起时的租户,命令行宿主用 --tenant。

HTTP、gRPC、MCP 的网络入站不收 xihan.tenant 与 xihan.principal(ADR-0121):baggage 头是调用方自己写的,解析器只核对租户存在且启用,不核对调用方属不属于它。所以这些调用方的租户只来自认证(令牌或 ApiKey 的租户声明)。

CloudEvents 入站端点是例外:事件的 xihantenant 由发送方给,经消息派发进 Baggage,不和请求的身份核对。这个端点的权限要由宿主自己加,见 CloudEvents(ADR-0121)。

要按主机名或请求头选租户时,登记自己的解析器,由它决定信什么、核对什么:

csharp
public sealed class SubdomainTenantResolver(IHttpContextAccessor http) : ITenantResolver
{
    public ValueTask<Maybe<string>> ResolveAsync(InvocationContext context, CancellationToken cancellationToken)
    {
        // acme.example.com → acme;中间件接着核对租户存在且启用
        var host = http.HttpContext?.Request.Host.Host;
        var tenant = host?.Split('.')[0];
        return ValueTask.FromResult(string.IsNullOrEmpty(tenant) ? default : Maybe.Some(tenant));
    }
}

配置 ​

MultiTenancyOptions 从配置节 XiHan:MultiTenancy 读取(MultiTenancyOptions.FromConfiguration):

键类型缺省说明
DefaultTenantstring无解析器都没有给出值时使用;同样经过文法与存储校验;空字符串表示清除
RequireTenantboolfalse为真时没有租户的调用返回 MULTITENANCY_VALIDATION_002
ValidateAgainstStorebooltrue为真时租户必须在存储中存在且启用;为假时只校验文法
TenantClaimstringtenantClaimTenantResolver 读取的声明类型,不能为空
ShardCacheSizeint10000TenantStoreShardResolver 按租户缓存分片结果的条目上限,满时整体清空;必须是正整数

布尔值只接受 true / false,ShardCacheSize 只接受正整数,其他写法在启动时抛出 ArgumentException。

json
{
  "XiHan": {
    "MultiTenancy": {
      "RequireTenant": true,
      "TenantClaim": "tenant"
    }
  }
}

租户与主体声明 ​

认证 Provider 把租户写成主体的声明,ClaimTenantResolver 再从声明里取出来:

认证方式租户声明从哪来
Security.InMemory 的 ApiKey主体记录的 Tenant,写成类型为 TenantClaim 的声明(读同一个配置键 XiHan:MultiTenancy:TenantClaim)
Security.Jwt / Security.OpenIdConnect令牌里类型等于 TenantClaim 的声明,声明按原始类型名保留

HTTP 调用方的租户只来自声明。以 SimpleApp 为例(alice 的记录是 Tenant: acme):

调用结果
alice 的 ApiKey租户 acme
alice 的 ApiKey,请求头 baggage: xihan.tenant=globex租户仍是 acme,这个键入站不收
匿名,请求头 baggage: xihan.tenant=acme无租户,这个键入站不收
匿名,RequireTenant 为真MULTITENANCY_VALIDATION_002

主体没有租户声明时(匿名调用、平台级账号)就没有租户。平台账号要进入某个租户,由应用换发带租户声明的令牌,并在换发时核对成员关系。

持久化层的租户过滤 ​

实体实现 IMultiTenant(XiHan.Framework.Domain.Entities)就成为租户数据:

csharp
using XiHan.Framework.Domain.Entities;

public sealed class Invoice : IEntity<Guid>, IMultiTenant
{
    public Guid Id { get; set; }

    // 新增时由持久化 Provider 写入工作单元的租户,调用方不用赋值
    public string TenantId { get; set; } = string.Empty;

    public decimal Amount { get; set; }
}

Transaction 阶段的 UnitOfWorkMiddleware 用安全上下文里的租户开工作单元(UnitOfWorkOptions.TenantId),持久化 Provider 据此施加规则:

  • 新增多租户实体时写入工作单元的租户;工作单元没有租户时返回 PERSISTENCE_VALIDATION_001。
  • 查询只返回 TenantId 等于当前租户的行;没有租户的调用看不到任何多租户实体。
  • 更新、删除不属于当前租户的实体,与实体不存在一样返回 PERSISTENCE_NOTFOUND_001。

租户目录(TenantRecord)、权限授予(PermissionGrantRecord)、审计记录这类平台数据不实现 IMultiTenant,不受当前租户过滤,租户在它们自己的列里。仓储与工作单元的完整用法见持久化与工作单元与事务。

租户分片 ​

分片(ADR-0018)是 multitenancy.sharding@1:按租户把数据落到租户自己的库。框架只做库级隔离;行级隔离就是上一节的租户过滤,Schema 级隔离由应用经透传属性自行处理。

分片解析器契约是 ITenantShardResolver:ResolveAsync(tenantId) 返回 Result<Maybe<TenantShard>>,共库租户与未知租户是成功且无值,租户存储不可达返回 MULTITENANCY_UNAVAILABLE_002;Invalidate(tenantId?) 丢弃缓存,参数为 null 时清空全部。TenantShard 带租户 ID、连接串、可选的提供者名(转成小写,如 postgresql)与透传属性。

两个多租户 Provider 在分片能力被选中时都注册缺省解析器 TenantStoreShardResolver,它从租户属性推出分片:

租户属性键常量含义
ConnectionStringTenantShardKeys.ConnectionString有值即该租户分库;没有或为空白是共库租户
DatabaseProviderTenantShardKeys.DatabaseProvider数据库提供者名,可选;空表示与应用默认连接同一种
Shard.<key>TenantShardKeys.PropertyPrefix去掉前缀后作为 <key> 透传给持久化 Provider;用点不用冒号,因为属性来自配置时冒号是节分隔符
json
{
  "XiHan": {
    "MultiTenancy": {
      "Tenants": {
        "acme": {
          "DisplayName": "Acme Corp",
          "Properties": {
            "ConnectionString": "Host=localhost;Database=acme",
            "DatabaseProvider": "postgresql"
          }
        },
        "beta": { "DisplayName": "Beta" }
      }
    }
  }
}

解析结果按租户缓存,"无分片"也缓存;缓存条目数达到 ShardCacheSize(10000)时整体清空。租户连接变更后调用 ITenantShardResolver.Invalidate(tenantId);经 TenantRecordStore 写入的数据库目录会自动失效。

分片在 Tenant 阶段解析一次,写进调用上下文(context.GetTenantShard() 可读);解析失败即调用失败,不降级为共库,因为降级会把租户数据写进平台库。无租户的调用没有分片。

持久化 Provider(Persistence.SqlSugar、Persistence.EntityFrameworkCore)以可选依赖声明 multitenancy.sharding@1,在作用域内创建客户端时经 TenantShardBinding.Resolve 读分片,不访问存储、不阻塞。分片是实体级的落点,不是整个作用域的切换:带分片的调用里,实现 IMultiTenant 的实体走分片连接,平台表仍走默认连接。

Provider带分片的调用
Persistence.SqlSugar客户端同时持有默认连接与分片连接(ConfigId),工作单元经 AsTenant() 在两个连接上开事务、依次提交或回滚(不是两阶段提交),见 Persistence.SqlSugar · 多库落点
Persistence.EntityFrameworkCore一个 DbContext 一个连接,整个上下文切到分片连接;平台表要另起 DbContext,由应用处理

分片库的表结构由应用管理

框架的建表开关(SqlSugar 的 InitTables、EF Core 的 EnsureCreated)只作用于默认连接。租户库的建表与升级由应用自己执行,下一节的 TenantShardScope 用于在管道外连到租户库。EF Core 在 SQLite 上的时间列核对对分片库在首次绑定时执行(工作单元开始时,ADR-0097):分片库还是旧的文本形式时,这个租户的工作单元开不起来,经管道的调用是 KERNEL_UNEXPECTED_001(HTTP 500,原因 PERSISTENCE_VALIDATION_008;已定,ADR-0099),修好库之后下一次绑定即恢复,见 EF Core 的已有 SQLite 库。

管道外绑定分片 ​

后台写入、调度作业、租户库初始化这类代码自己建服务作用域,不经 App.InvokeAsync,作用域里没有调用上下文,也就没有分片。作用域服务 TenantShardScope(ADR-0057)给这类作用域显式绑定分片:

成员说明
BindTenantAsync(tenantId, cancellationToken)经 ITenantShardResolver 解析并绑定,返回解析结果;不看租户是否启用,也不受应用是否在服务状态(启动、排空)影响;共库租户、未知租户与没有装配分片解析器时成功且不绑定;解析失败返回错误、不绑定
Bind(shard)直接绑定一个分片
Shard已绑定的分片,没有绑定为无值
IsConnected作用域的持久化客户端是否已经取过连接

绑定必须发生在作用域内首次创建持久化客户端之前。客户端建好之后再绑定,或改绑到另一个分片,抛出 InvalidOperationException;同一个分片重复绑定没有副作用。显式绑定优先于调用上下文里的分片。

csharp
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.MultiTenancy.Abstractions.Sharding;
using XiHan.Framework.Persistence.Abstractions.Queries;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.Abstractions.UnitOfWork;
using XiHan.Framework.Primitives;

// Invoice 见上一节,仓储已经 AddRepository<Invoice, Guid>() 登记
public sealed class InvoiceCounter(IServiceScopeFactory scopes)
{
    public async Task<Result<long>> CountAsync(string tenantId, CancellationToken cancellationToken)
    {
        await using var scope = scopes.CreateAsyncScope();
        var services = scope.ServiceProvider;

        // 先绑定再解析任何持久化服务:客户端在作用域里只建一次,建好后改不了连接
        var bound = await services.GetRequiredService<TenantShardScope>().BindTenantAsync(tenantId, cancellationToken);
        if (bound.IsFailure)
        {
            return bound.Error!;
        }

        // 没有 Transaction 阶段代劳,工作单元自己开;租户由调用方给出,与绑定的租户一致
        var unitOfWork = services.GetRequiredService<IUnitOfWork>();
        await unitOfWork.BeginAsync(new UnitOfWorkOptions(tenantId, IsReadOnly: true), cancellationToken);
        try
        {
            var invoices = services.GetRequiredService<IRepository<Invoice, Guid>>();
            var count = await invoices.CountAsync(EntityQuery.All<Invoice>(), cancellationToken);
            var committed = await unitOfWork.CommitAsync(cancellationToken);
            return committed.IsFailure ? committed.Error! : count;
        }
        catch
        {
            if (unitOfWork.State == UnitOfWorkState.Active)
            {
                await unitOfWork.RollbackAsync(CancellationToken.None);
            }

            throw;
        }
    }
}

工作单元的租户不会从绑定推导出来,两者都由调用方给出;管道外开工作单元、派发领域事件的完整写法见工作单元与事务。两个多租户 Provider 都登记 TenantShardScope,与是否选中分片能力无关;应用自带分片 Provider 时同样要登记它。

数据库租户目录 ​

MultiTenancy.Database(ADR-0026)把租户目录放进数据库:每个租户一行 TenantRecord(主键是租户 ID,显示名、启用标记、属性 JSON、创建与修改时间、并发戳),经 persistence.relational@1 的仓储读写,所以还要装配一个持久化 Provider。解析顺序、DefaultTenant / RequireTenant / ValidateAgainstStore 与分片语义与内存实现相同,只换了存储。

读写分成两个类型:

  • DatabaseTenantStore(单例 ITenantStore):Tenant 阶段查它。读取不经管道,而是在根提供者的新作用域里开只读工作单元(配了从库也读主库,ADR-0122),因为嵌套调用会再过一遍 Tenant 阶段,RequireTenant 为真时会自我拒绝,启动期也读不了。查找结果按租户缓存,"不存在"也缓存。
  • TenantRecordStore(作用域服务):管理操作在 Transaction 阶段内用它批量新增、覆盖、删除租户,逐项返回结果;写入后立即失效目录与分片解析器的缓存。

缓存与枚举的配置在 XiHan:MultiTenancy:Database:

键缺省说明
CacheSize10000缓存条目上限,"不存在"也占一条;满时整体清空
CacheTimeToLiveSeconds30每条缓存的存活时间;0 表示不过期,只靠 Invalidate 失效
MaxListedTenants10000ListAsync 最多返回的租户数,超出部分不返回并记警告

写入在调用方事务提交后才对其他调用可见,失效与提交之间的窗口靠存活时间收敛;多实例部署时其他实例的缓存同样靠存活时间收敛。绕过 TenantRecordStore 直接改表时,自行调用 DatabaseTenantStore.Invalidate 与 ITenantShardResolver.Invalidate。装配与示例代码见 MultiTenancy.Database。

错误码 ​

错误码含义HTTP
MULTITENANCY_VALIDATION_001租户 ID 不合法400
MULTITENANCY_VALIDATION_002调用必须带租户但没有解析到400
MULTITENANCY_NOTFOUND_001租户不存在;TenantRecordStore.RemoveAsync 删除不存在的租户也返回它404
MULTITENANCY_DENIED_001租户已禁用403
MULTITENANCY_UNAVAILABLE_001内存租户存储容量已满(InMemoryTenantStore.Set 与健康项)503
MULTITENANCY_UNAVAILABLE_002租户分片无法解析(租户存储不可达),reason 只写异常类型的全名,完整异常进日志(ADR-0115)503
PERSISTENCE_VALIDATION_001多租户实体在没有租户的调用中新增400
KERNEL_CAPABILITY_001装配了 MultiTenancy.Database 却没有持久化 Provider,启动中断—

HTTP 状态码按错误码的种类段映射,与其他子系统一致,见结果与错误。

与其他能力的关系 ​

  • 认证:认证 Provider 把租户写成主体声明,Tenant 阶段在认证之后读它;认证阶段写安全上下文时保留已有租户。
  • 授权:PermissionChecker 按安全上下文里的租户取授予,租户专属授予只对该租户生效。
  • 缓存与分布式锁:Idempotency 阶段的幂等键按租户与主体隔离。
  • 审计:审计记录带租户 ID;审计表是平台数据,独立库租户的记录同样落默认连接。
  • 调度与后台作业:作业里访问租户库时用 TenantShardScope 绑定分片。

相关 ​

Released under The MIT License