XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

数据库租户目录 ​

这一页讲 MultiTenancy.Database:把租户目录从配置搬进数据库,租户作为实体经仓储读写,管理操作在运行时增删租户,多个实例共享同一份目录。读完你能装配数据库目录、写一个创建与停用租户的管理操作,并知道缓存怎样失效、启动时为什么会核对持久化 Provider。阅读前先读租户解析与隔离与仓储读写。

为什么需要 ​

内存目录从配置装载租户,适合租户随配置发布、数量少的应用;但 SaaS 的租户是运营动作的产物:后台开通一个、停用一个、改一条连接串,不能每次都改配置重新部署,多实例部署时各实例还得看到同一份目录。MultiTenancy.Database 只换存储:解析顺序、DefaultTenant / RequireTenant / ValidateAgainstStore 与分片语义都在抽象包里,与内存实现相同。租户是一行 TenantRecord,经 persistence.relational@1 的仓储读写,所以要和一个持久化 Provider 一起装配。

两个类型 ​

读写分成两个类型,各自解决一个问题:

类型生命周期作用
DatabaseTenantStore单例 ITenantStoreTenant 阶段查它。它在根提供者的新作用域里直接开只读工作单元读仓储(配了从库也读主库),不经 App.InvokeAsync:目录是 Tenant 阶段自己要查的东西,嵌套调用会再过一遍 Tenant 与 Authorize 阶段,RequireTenant 为真时会自我拒绝,启动期也读不了。查找结果按租户缓存,「不存在」也缓存,每条带存活时间
TenantRecordStore作用域服务管理操作在 Transaction 阶段内用调用方的工作单元批量新增、覆盖、删除租户,逐项返回结果;写入后立即失效目录与分片解析器的缓存

存储行 TenantRecord 的列:

列说明
Id主键,就是租户 ID,长度 128
DisplayName显示名,长度 256
IsEnabled启用标记
PropertiesJson整份租户属性的 JSON 对象,长文本类型
CreatedAt、ModifiedAt创建与修改时间
ConcurrencyStamp并发戳

TenantRecord 是平台级数据:不实现 IMultiTenant,不受当前租户过滤,不软删除。

其他类型说明
DatabaseMultiTenancyFeature特性入口(清单 xihan.multitenancy.database,role: provider):登记 TenantRecord 的仓储、目录、解析器与 Tenant 阶段中间件,启动时核对持久化 Provider
DatabaseMultiTenancyOptionsCacheSize、CacheTimeToLive(TimeSpan?,null 不过期)、MaxListedTenants
TenantRecordResult一项写入结果:TenantId、Outcome(Result)

最小示例 ​

bash
dotnet add package XiHan.Framework.MultiTenancy.Database --prerelease

本包必须和一个持久化 Provider 一起装配。下面用 Persistence.SqlSugar,它按约定映射 TenantRecord,不需要登记:

csharp
using SqlSugar;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.MultiTenancy.Database;
using XiHan.Framework.Persistence.SqlSugar;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// 显式 Provider 顶替 Standard 组合包里的内存持久化与内存多租户
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature(new SqlSugarPersistenceFeature(static (binding, config) =>
    {
        config.DbType = DbType.PostgreSQL;
        config.ConnectionString = binding.ValueOrThrow().Value;
    }))
    .UseFeature<DatabaseMultiTenancyFeature>());

var app = builder.Build();
app.UseXiHan();
app.Run();
json
{
  "XiHan": {
    "Bindings": { "db": "Host=localhost;Database=app" },
    "MultiTenancy": {
      "RequireTenant": false,
      "Database": { "CacheSize": 10000, "CacheTimeToLiveSeconds": 30, "MaxListedTenants": 10000 }
    }
  }
}

启动后 Tenant 阶段改为查数据库:主体带租户声明 acme 时,TenantRecord 表里要有 Id = acme 且 IsEnabled 为真的行,否则调用以 MULTITENANCY_NOTFOUND_001 或 MULTITENANCY_DENIED_001 结束。配置里的 XiHan:MultiTenancy:Tenants 不再被读取。

Persistence.EntityFrameworkCore 的应用在自己的 DbContext 里登记实体,属性列用长文本类型:

csharp
using Microsoft.EntityFrameworkCore;
using XiHan.Framework.MultiTenancy.Database.Tenants;

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    // PropertiesJson 是整份租户属性的 JSON 对象
    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<TenantRecord>(static t => t.Property(static x => x.PropertiesJson).HasColumnType("text"));
}

配置 ​

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

键类型缺省说明
CacheSizeint10000目录缓存条目上限,「不存在」也占一条;满时整体清空
CacheTimeToLiveSecondsdouble30每条缓存的存活时间;0 表示不过期,只靠 Invalidate 失效
MaxListedTenantsint10000DatabaseTenantStore.ListAsync 最多返回的租户数,超出部分不返回并记警告

解析选项 DefaultTenant、RequireTenant、ValidateAgainstStore、TenantClaim 仍在 XiHan:MultiTenancy,见租户解析与隔离。持久化 Provider 的连接来自绑定,SqlSugar 的绑定名是 db,见资源绑定。

管理租户 ​

管理操作在处理器里经 TenantRecordStore 写入;Transaction 阶段已经开了工作单元,写入随调用一起提交:

csharp
using XiHan.Framework.MultiTenancy.Abstractions.Sharding;
using XiHan.Framework.MultiTenancy.Abstractions.Tenants;
using XiHan.Framework.MultiTenancy.Database.Tenants;
using XiHan.Framework.Primitives;

// 作用域服务,在操作的处理器里使用
public sealed class TenantOnboarding(TenantRecordStore tenants)
{
    public async ValueTask<Result> CreateAsync(string tenantId, string displayName, string? connectionString, CancellationToken cancellationToken)
    {
        var properties = new Dictionary<string, string>(StringComparer.Ordinal);
        if (connectionString is not null)
        {
            // 有连接串即分库;写入后目录与分片解析器的缓存随即失效
            properties[TenantShardKeys.ConnectionString] = connectionString;
        }

        var results = await tenants.UpsertAsync([new TenantInfo { Id = tenantId, DisplayName = displayName, Properties = properties }], cancellationToken);
        return results[0].Outcome;
    }

    // 整份覆盖:先读出当前记录再改启用标记写回,否则显示名与属性会被清空
    public async ValueTask<IReadOnlyList<TenantRecordResult>> DisableAsync(IReadOnlyList<TenantInfo> current, CancellationToken cancellationToken)
        => await tenants.UpsertAsync([.. current.Select(static t => t with { IsEnabled = false })], cancellationToken);

    // 不存在的 ID 逐项返回 MULTITENANCY_NOTFOUND_001
    public ValueTask<IReadOnlyList<TenantRecordResult>> RemoveAsync(IReadOnlyList<string> tenantIds, CancellationToken cancellationToken)
        => tenants.RemoveAsync(tenantIds, cancellationToken);

    // 按 ID 分页列出
    public Task<IReadOnlyList<TenantInfo>> PageAsync(int skip, int take, CancellationToken cancellationToken)
        => tenants.ListAsync(skip, take, cancellationToken);
}

三个批量方法的语义:

方法行为
UpsertAsync(tenants)一次查询取回已有行,已有的更新、没有的新增,各用一次批量方法写入;同一批里同一租户后者整份覆盖前者,同一行只写一次,各项结果相同。整份覆盖:只想改启用标记时,先读出当前的 TenantInfo 再整体写回
RemoveAsync(tenantIds)一次批量删除;不存在的 ID 与同一批里重复出现的 ID 逐项返回 MULTITENANCY_NOTFOUND_001
ListAsync(skip, take)按 ID 分页

不合法的租户 ID 是编程错误,整批在访问数据库前抛出 ArgumentException;空集合直接返回空结果。这些方法由管理操作调用,操作契约要写上权限码(如 tenants.manage),由授权与权限挡住无权的调用方。

缓存与失效 ​

  • DatabaseTenantStore.FindAsync 的结果按租户缓存,「不存在」也缓存;条目按 CacheTimeToLive 过期,条目数达到 CacheSize 时整体清空,不逐条淘汰。不合法的租户 ID 直接返回无值,不查库,也不占缓存。
  • TenantRecordStore 在写入时就失效目录与分片解析器(ITenantShardResolver.Invalidate)的缓存。写入在调用方事务提交后才对其他调用可见,失效与提交之间的窗口由存活时间收敛。
  • 多实例部署时其他实例的缓存同样靠存活时间收敛,需要更快生效时调小 CacheTimeToLiveSeconds。
  • 绕过 TenantRecordStore 直接改表时,自行调用 DatabaseTenantStore.Invalidate(tenantId) 与 ITenantShardResolver.Invalidate(tenantId),或等存活时间到期。Invalidate() 不带参数时清空全部。
  • ListAsync 不走缓存,按 ID 每页 1000 条读完;超过 MaxListedTenants 的部分不返回,并记一条警告。

启动期核对持久化 Provider ​

本包的清单不声明 persistence.relational@1:持久化 Provider 以可选依赖引用 multitenancy.sharding@1 来按分片选连接,内核把满足了的可选依赖也当作排序边,再声明持久化就成环。实际依赖改在 StartAsync 核对,能力图谱里没有持久化 Provider 时以 KERNEL_CAPABILITY_001 中断启动,不会带着一个查不了库的目录运行。

特性只为被选中的能力注册服务:另有显式 Provider 提供租户上下文时,本包不注册存储、解析器与中间件;另有分片 Provider 时不注册 TenantStoreShardResolver。TenantShardScope 总会注册。

行为与限制 ​

  • 故障:连接失败、提交失败这类基础设施异常按 ITenantStore 契约原样抛出,不伪装成租户不存在;Tenant 阶段据此让调用失败,分片解析器把它映射为 MULTITENANCY_UNAVAILABLE_002。
  • 表结构由应用管理,InitTables / EnsureCreated 只用于开发与测试。PropertiesJson 用长文本类型(text / nvarchar(max) / longtext)。分片库的表结构同样由应用管理。
  • 测试里可以与 Persistence.InMemory 一起装配;本包通过 MultiTenancy.Conformance 的 TenantStoreConformance 与 TenantShardResolverConformance,并在 SQLite(Persistence.SqlSugar)上验证了实体映射。
错误码含义HTTP
MULTITENANCY_NOTFOUND_001租户不存在;RemoveAsync 删除不存在的租户也逐项返回它404
MULTITENANCY_DENIED_001租户已禁用403
MULTITENANCY_UNAVAILABLE_002分片解析时租户存储不可达503
KERNEL_CAPABILITY_001装配了 MultiTenancy.Database 却没有持久化 Provider,启动中断—

常见问题 ​

刚创建的租户马上调用为什么还是 404 ​

TenantRecordStore 写入时已经失效本实例的缓存,但写入要等调用方的事务提交后才可见;如果创建与首次调用几乎同时发生,或首次调用落在另一个实例上,其他实例的「不存在」缓存要等 CacheTimeToLiveSeconds 到期。要更快生效就调小存活时间,或在创建流程里等待提交完成再通知客户端。

能用 TenantRecordStore 在后台任务里批量导入租户吗 ​

能,但后台任务没有 Transaction 阶段代劳,要自己建作用域、开工作单元、提交,写法见工作单元与事务。TenantRecordStore 是作用域服务,与工作单元在同一作用域内解析。

为什么 UpsertAsync 把我的属性清空了 ​

它是整份覆盖,不是局部更新。改一个字段时先经 ITenantStore.FindAsync 或 TenantRecordStore.ListAsync 读出当前 TenantInfo,用 with 改字段后整体写回。

配了读写分离,目录会读到从库的旧数据吗 ​

不会。DatabaseTenantStore 读目录时固定走主库,租户刚创建就能被核对到,不受从库复制延迟影响。

能同时保留配置里的租户和数据库里的租户吗 ​

不能。装配 DatabaseMultiTenancyFeature 后它是 multitenancy.context@1 的唯一 Provider,XiHan:MultiTenancy:Tenants 不再读取。要迁移时先把配置里的租户用 UpsertAsync 导入表里。

相关包与下一步 ​

Released under The MIT License