ABP - SaaS多租户(Multi-Tenancy)[ITenantRepository、ICurrentTenant、MultiTenantAttribute]

(1)SaaS多租户(Multi-Tenancy)

核心辅助类

  • ITenantRepository:租户仓储。
  • ICurrentTenant:当前租户信息。
  • MultiTenantAttribute:标记多租户支持。

SaaS(软件即服务)多租户是指一个系统同时为多个租户(如不同公司、部门)提供服务,每个租户的数据相互隔离但共享同一套系统部署。ABP框架原生支持多租户,通过ITenantRepositoryICurrentTenant等工具实现租户管理和数据隔离,下面用通俗的例子讲解。

一、核心概念:什么是多租户?(生活例子)

想象一个写字楼:

  • 整栋楼是“系统部署”(一套硬件和基础软件);
  • 每个公司租的办公室是“租户”(独立空间,数据隔离);
  • 办公室门牌号是“租户ID”(区分不同租户);
  • 清洁工有“万能钥匙”(主机管理员,可访问所有租户数据)。

在程序中,多租户就是多个客户共用一套系统,但各自的数据(如订单、用户)互不干扰,就像每个客户有独立的数据库,但实际上可能共享一个数据库(通过租户ID区分)。

二、核心类说明

类/特性 核心作用 通俗理解
ITenantRepository 租户仓储(管理租户信息,如租户ID、名称、连接字符串) 相当于“写字楼前台”,记录所有租户的信息
ICurrentTenant 当前租户上下文(获取当前访问的租户ID、名称等) 相当于“当前所在办公室的门牌号”
MultiTenantAttribute 标记实体/服务支持多租户(自动添加租户隔离) 给数据“贴标签”,说明它属于哪个租户

三、实战示例:从租户管理到数据隔离

1. 定义多租户实体(MultiTenantAttribute

要实现数据隔离,首先需要让实体“知道自己属于哪个租户”。通过[MultiTenant]特性标记实体,ABP会自动为实体添加TenantId字段(租户ID)。

示例:多租户商品实体

using Volo.Abp.Data;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Domain.Entities;

// 标记为多租户实体:自动添加TenantId字段
[MultiTenant]
public class Product : AggregateRoot<Guid>
{
    // ABP自动维护TenantId(无需手动定义),表示该商品属于哪个租户
    public string Name { get; set; } // 商品名称
    public decimal Price { get; set; } // 价格
}

原理:

  • [MultiTenant]后,实体在数据库中的表会多一个TenantId字段;
  • 租户A的商品TenantIdA的ID,租户B的商品TenantIdB的ID,查询时自动过滤,互不干扰。

2. 管理租户(ITenantRepository

租户信息(如租户ID、名称、数据库连接字符串)需要存储和管理,ITenantRepository是操作租户数据的仓储。

示例:创建租户(主机管理员功能)

using Volo.Abp.MultiTenancy;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Application.Services;

// 主机级服务(只有主机管理员能访问)
public class TenantAppService : ApplicationService
{
    private readonly ITenantRepository _tenantRepo;
    private readonly ITenantManager _tenantManager; // 租户管理工具

    public TenantAppService(ITenantRepository tenantRepo, ITenantManager tenantManager)
    {
        _tenantRepo = tenantRepo;
        _tenantManager = tenantManager;
    }

    // 创建新租户(如“百度公司”作为一个租户)
    public async Task CreateTenantAsync(CreateTenantInput input)
    {
        // 1. 生成租户ID(可自定义,也可自动生成)
        var tenantId = Guid.NewGuid();

        // 2. 创建租户(包含名称、管理员邮箱等信息)
        var tenant = await _tenantManager.CreateAsync(
            tenantId: tenantId,
            name: input.Name, // 租户名称(如“百度”)
            adminEmailAddress: input.AdminEmail, // 租户管理员邮箱
            adminPassword: input.AdminPassword // 租户管理员密码
        );

        // 3. 可选:为租户配置独立数据库(默认共享数据库,通过TenantId隔离)
        if (!string.IsNullOrEmpty(input.ConnectionString))
        {
            tenant.ConnectionStrings.Add(
                ConnectionStringName.Default, // 数据库连接字符串名称
                input.ConnectionString // 租户独立的数据库连接字符串
            );
        }

        // 4. 保存租户信息到数据库
        await _tenantRepo.InsertAsync(tenant);
    }
}

// 创建租户的输入参数
public class CreateTenantInput
{
    public string Name { get; set; } // 租户名称
    public string AdminEmail { get; set; } // 租户管理员邮箱
    public string AdminPassword { get; set; } // 租户管理员密码
    public string ConnectionString { get; set; } // 可选:租户独立数据库连接字符串
}

3. 获取当前租户信息(ICurrentTenant

在代码中,通过ICurrentTenant可以获取当前访问系统的租户信息(如租户ID),用于业务逻辑判断或数据过滤。

示例:根据当前租户查询数据

using Volo.Abp.MultiTenancy;
using Volo.Abp.Application.Services;

public class ProductAppService : ApplicationService
{
    private readonly IRepository<Product, Guid> _productRepo;
    private readonly ICurrentTenant _currentTenant; // 当前租户上下文

    public ProductAppService(IRepository<Product, Guid> productRepo, ICurrentTenant currentTenant)
    {
        _productRepo = productRepo;
        _currentTenant = currentTenant;
    }

    // 查询当前租户的商品(自动过滤其他租户数据)
    public async Task<List<ProductDto>> GetMyProductsAsync()
    {
        // 获取当前租户ID(如果是主机管理员,可能为null)
        var tenantId = _currentTenant.Id;
        if (tenantId == null)
        {
            throw new UserFriendlyException("只有租户用户能访问此接口");
        }

        // 查询当前租户的商品(ABP会自动在查询条件中添加TenantId == 当前租户ID)
        var products = await _productRepo.GetListAsync();
        
        // 注意:ABP的仓储会自动过滤多租户数据,无需手动写Where条件!
        return ObjectMapper.Map<List<Product>, List<ProductDto>>(products);
    }
}

关键特性:自动数据隔离

  • 当调用_productRepo.GetListAsync()时,ABP会自动生成SQL:WHERE TenantId = '当前租户ID'
  • 租户A的用户只能看到TenantId = A的ID的商品,租户B的用户只能看到自己的商品,天然隔离。

4. 主机与租户的权限控制

多租户系统中有两种角色:

  • 主机(Host):系统管理员,可管理所有租户;
  • 租户(Tenant):普通客户,只能管理自己的数据。

通过[AllowAnonymous][Authorize]结合租户判断实现权限控制:

示例:主机才能访问的接口

public class TenantManagementAppService : ApplicationService
{
    private readonly ICurrentTenant _currentTenant;
    private readonly ITenantRepository _tenantRepo;

    public TenantManagementAppService(ICurrentTenant currentTenant, ITenantRepository tenantRepo)
    {
        _currentTenant = currentTenant;
        _tenantRepo = tenantRepo;
    }

    // 只有主机管理员能查看所有租户列表
    public async Task<List<TenantDto>> GetAllTenantsAsync()
    {
        // 检查是否是主机(主机的TenantId为null)
        if (_currentTenant.Id != null)
        {
            throw new UserFriendlyException("只有主机管理员能访问此接口");
        }

        // 主机可以查询所有租户
        var tenants = await _tenantRepo.GetListAsync();
        return ObjectMapper.Map<List<Tenant>, List<TenantDto>>(tenants);
    }
}

5. 多租户数据存储模式(两种常见方式)

ABP支持两种多租户数据隔离模式,通过配置选择:

模式1:共享数据库,独立表字段(默认,推荐)

  • 所有租户共用一个数据库,表中通过TenantId字段区分数据;

  • 优点:部署简单,维护成本低;

  • 配置(appsettings.json):

    "AbpMultiTenancyOptions": {
      "IsEnabled": true,
      "DatabaseStyle": "Shared" // 共享数据库模式
    }
    

模式2:独立数据库

  • 每个租户有自己的数据库,通过ConnectionString指定;

  • 优点:数据隔离性极高,适合对数据安全要求严格的场景;

  • 配置:

    "AbpMultiTenancyOptions": {
      "IsEnabled": true,
      "DatabaseStyle": "Separate" // 独立数据库模式
    }
    

    (需在创建租户时指定ConnectionString,如步骤2示例)

四、租户识别:系统如何知道“当前是哪个租户”?

ABP通过以下方式识别当前租户(优先级从高到低):

  1. 请求头__tenant(如__tenant: baidu);
  2. 查询参数?__tenant=baidu
  3. Cookie__tenant
  4. 域名:配置子域名映射(如baidu.myapp.com对应租户baidu)。

前端调用示例(Axios设置租户头):

// 租户用户访问时,在请求头中携带租户ID
axios.interceptors.request.use(config => {
  config.headers['__tenant'] = 'baidu'; // 租户标识(如租户ID或名称)
  return config;
});

五、新手避坑指南

  1. 实体必须加[MultiTenant]:否则ABP不会自动添加TenantId,导致数据无法隔离;

  2. 主机与租户的区分:主机的ICurrentTenant.Idnull,租户用户的Id为具体租户ID;

  3. 共享数据库的索引优化TenantId字段建议加索引,提升查询性能(ABP自动创建);

  4. 全局数据的处理:如果某些数据需要所有租户共享(如公共字典),实体不要加[MultiTenant],并在查询时忽略租户过滤:

    // 查询全局数据(忽略租户过滤)
    var globalData = await _globalRepo.GetListAsync(includeDetails: true, ignoreMultiTenancy: true);
    

总结

  • 核心目标:多租户通过TenantId实现数据隔离,让多个客户共享一套系统但数据互不干扰;
  • 关键工具
    • [MultiTenant]:标记实体支持多租户,自动添加TenantId
    • ICurrentTenant:获取当前租户信息,用于业务逻辑;
    • ITenantRepository:管理租户信息(创建、查询租户);
  • 适用场景:SaaS平台(如阿里云、钉钉)、企业多部门系统等需要数据隔离的场景。

(2)多租户域名配置与租户数据迁移实战示例

在SaaS多租户系统中,域名区分租户(如tenant1.yourdomain.com对应租户1)和租户数据迁移(如从共享数据库迁移到独立数据库)是常见需求。下面结合ABP框架详细讲解实现方案。

一、多租户域名配置(通过子域名区分租户)

通过子域名自动识别租户(无需在请求头/参数中指定__tenant),是最友好的多租户访问方式。例如:

  • 租户A:a.yourdomain.com
  • 租户B:b.yourdomain.com
  • 主机管理:host.yourdomain.com

1. 核心原理

ABP通过ITenantResolveContributor接口扩展租户识别方式,我们只需实现“从域名解析租户”的逻辑,框架会自动关联租户ID。

2. 实现步骤

步骤1:创建域名租户解析器

using Volo.Abp.MultiTenancy;
using Microsoft.AspNetCore.Http;
using System.Linq;

// 从子域名解析租户(如"a.yourdomain.com"解析为租户"a")
public class DomainTenantResolveContributor : ITenantResolveContributor
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public DomainTenantResolveContributor(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task ResolveAsync(ITenantResolveContext context)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext == null)
        {
            await Task.CompletedTask;
            return;
        }

        var host = httpContext.Request.Host.Host; // 获取域名(如"a.yourdomain.com")
        var domainParts = host.Split('.');

        // 排除主机域名(如"host.yourdomain.com")和顶级域名(如"yourdomain.com")
        if (domainParts.Length >= 3 && domainParts[0] != "host")
        {
            // 子域名作为租户名(如"a")
            context.TenantIdOrName = domainParts[0];
        }

        await Task.CompletedTask;
    }
}

步骤2:注册解析器(在模块中)

public override void ConfigureServices(ServiceConfigurationContext context)
{
    Configure<AbpTenantResolveOptions>(options =>
    {
        // 将域名解析器添加到租户识别链(优先级高于默认解析器)
        options.TenantResolvers.Insert(0, typeof(DomainTenantResolveContributor));
        // 注意:默认解析器包括Header、QueryString、Cookie,插入到最前面优先生效
    });
}

步骤3:配置租户与域名映射(种子数据)

在租户创建时,记录租户对应的子域名(可选,用于验证):

// 扩展租户实体,添加域名字段
public class TenantExtension : Tenant
{
    public virtual string Domain { get; set; } // 如"a"对应"a.yourdomain.com"

    // 构造函数(必须)
    public TenantExtension(Guid id, string name) : base(id, name) { }
}

// 种子数据中创建租户时指定域名
public async Task SeedAsync(DataSeedContext context)
{
    // 创建租户A,域名"a"
    var tenantA = await _tenantManager.CreateAsync("tenantA", "admin@a.com", "123456");
    tenantA.SetProperty("Domain", "a"); // 用扩展属性存储域名
    await _tenantRepo.InsertAsync(tenantA);
}

步骤4:配置本地测试域名(hosts文件)

为了本地测试,修改C:\Windows\System32\drivers\etc\hosts

127.0.0.1  a.yourdomain.com
127.0.0.1  b.yourdomain.com
127.0.0.1  host.yourdomain.com

启动项目后,访问http://a.yourdomain.com:5000会自动识别为租户A。

3. 进阶:支持自定义域名(租户自有域名)

如果租户需要使用自有域名(如tenantA.com),可扩展解析逻辑:

// 在DomainTenantResolveContributor的ResolveAsync中添加:
var customDomainTenant = await _tenantRepository.FindAsync(t => t.GetProperty<string>("CustomDomain") == host);
if (customDomainTenant != null)
{
    context.TenantIdOrName = customDomainTenant.Name;
}

租户配置自有域名后,需在DNS解析中将tenantA.com指向你的服务器IP。

二、租户数据迁移(从共享数据库到独立数据库)

当租户数据量增长或有独立部署需求时,需要将租户数据从共享数据库迁移到独立数据库。

1. 迁移流程设计

1. 准备独立数据库 → 2. 从共享库导出租户数据 → 3. 导入独立库 → 4. 更新租户连接字符串 → 5. 验证数据完整性

2. 实现步骤

步骤1:创建迁移服务

public class TenantDataMigrationService : ITransientDependency
{
    private readonly IRepository<Tenant, Guid> _tenantRepo;
    private readonly IDbContextProvider<MyAppDbContext> _dbContextProvider;
    private readonly IConnectionStringResolver _connectionStringResolver;

    public TenantDataMigrationService(
        IRepository<Tenant, Guid> tenantRepo,
        IDbContextProvider<MyAppDbContext> dbContextProvider,
        IConnectionStringResolver connectionStringResolver)
    {
        _tenantRepo = tenantRepo;
        _dbContextProvider = dbContextProvider;
        _connectionStringResolver = connectionStringResolver;
    }

    // 迁移指定租户到独立数据库
    public async Task MigrateToSeparateDatabaseAsync(Guid tenantId, string newConnectionString)
    {
        // 1. 获取租户信息
        var tenant = await _tenantRepo.GetAsync(tenantId);
        var oldConnectionString = await _connectionStringResolver.ResolveAsync(
            ConnectionStringName.Default, 
            new TenantResolveContext { TenantIdOrName = tenant.Id.ToString() }
        );

        // 2. 准备独立数据库(创建库和表结构)
        await CreateDatabaseAndTablesAsync(newConnectionString);

        // 3. 从共享库导出租户数据
        var tenantData = await ExportTenantDataAsync(tenant.Id, oldConnectionString);

        // 4. 导入独立库
        await ImportTenantDataAsync(tenantData, newConnectionString);

        // 5. 更新租户连接字符串
        tenant.ConnectionStrings[ConnectionStringName.Default] = newConnectionString;
        await _tenantRepo.UpdateAsync(tenant);

        // 6. 可选:删除共享库中的租户数据
        await DeleteTenantDataFromSharedDbAsync(tenant.Id, oldConnectionString);
    }

    // 创建独立数据库和表结构(使用EF Core迁移)
    private async Task CreateDatabaseAndTablesAsync(string connectionString)
    {
        // 动态创建数据库(简化版,实际需用EF Core迁移API)
        var dbContextOptions = new DbContextOptionsBuilder<MyAppDbContext>()
            .UseSqlServer(connectionString)
            .Options;

        using (var dbContext = new MyAppDbContext(dbContextOptions, null))
        {
            await dbContext.Database.EnsureCreatedAsync(); // 创建库和表
        }
    }

    // 导出租户数据(查询所有带TenantId的表)
    private async Task<Dictionary<string, List<object>>> ExportTenantDataAsync(Guid tenantId, string connectionString)
    {
        // 实际需遍历所有多租户实体表,查询TenantId=tenantId的数据
        // 示例:导出Product表数据
        var data = new Dictionary<string, List<object>>();
        var dbContext = await _dbContextProvider.GetDbContextAsync();
        var products = await dbContext.Products
            .Where(p => p.TenantId == tenantId)
            .ToListAsync();
        data["Products"] = products.Cast<object>().ToList();
        // 其他表...
        return data;
    }

    // 导入数据到独立库
    private async Task ImportTenantDataAsync(Dictionary<string, List<object>> data, string connectionString)
    {
        var dbContextOptions = new DbContextOptionsBuilder<MyAppDbContext>()
            .UseSqlServer(connectionString)
            .Options;

        using (var dbContext = new MyAppDbContext(dbContextOptions, null))
        {
            // 禁用租户过滤(独立库无需TenantId)
            dbContext.DisableMultiTenancyFilter();

            // 导入Product表数据
            var products = data["Products"].Cast<Product>().ToList();
            await dbContext.Products.AddRangeAsync(products);
            // 其他表...
            await dbContext.SaveChangesAsync();
        }
    }

    // 从共享库删除租户数据
    private async Task DeleteTenantDataFromSharedDbAsync(Guid tenantId, string connectionString)
    {
        var dbContext = await _dbContextProvider.GetDbContextAsync();
        var products = await dbContext.Products
            .Where(p => p.TenantId == tenantId)
            .ToListAsync();
        dbContext.Products.RemoveRange(products);
        // 其他表...
        await dbContext.SaveChangesAsync();
    }
}

步骤2:创建迁移接口(供主机管理员调用)

[Authorize(HostPermissionNames.Tenants.Manage)] // 仅主机管理员可访问
public class TenantMigrationAppService : ApplicationService
{
    private readonly TenantDataMigrationService _migrationService;

    public TenantMigrationAppService(TenantDataMigrationService migrationService)
    {
        _migrationService = migrationService;
    }

    [HttpPost]
    public async Task MigrateTenantAsync(Guid tenantId, string newConnectionString)
    {
        // 验证连接字符串有效性
        if (!await IsConnectionStringValidAsync(newConnectionString))
        {
            throw new UserFriendlyException("数据库连接字符串无效");
        }

        // 执行迁移
        await _migrationService.MigrateToSeparateDatabaseAsync(tenantId, newConnectionString);
    }

    // 验证数据库连接
    private async Task<bool> IsConnectionStringValidAsync(string connectionString)
    {
        // 尝试连接数据库,返回是否成功
        using (var connection = new SqlConnection(connectionString))
        {
            try
            {
                await connection.OpenAsync();
                return true;
            }
            catch
            {
                return false;
            }
        }
    }
}

步骤3:迁移后的数据访问验证

迁移后,ABP会自动使用租户的新连接字符串访问数据:

// 租户访问自己的数据时,框架自动使用独立库连接字符串
public async Task<List<ProductDto>> GetProductsAsync()
{
    var products = await _productRepo.GetListAsync(); // 自动连接独立库
    return ObjectMapper.Map<List<Product>, List<ProductDto>>(products);
}

3. 注意事项

  • 数据一致性:迁移过程中建议暂停租户服务(或使用事务),避免数据写入冲突;
  • 索引与约束:迁移后需重建索引和外键约束,确保查询性能;
  • 历史数据:若租户有大量历史数据,建议分批次迁移(如按日期范围);
  • 回滚机制:迁移前备份数据,失败时可回滚到共享库。

三、总结

  • 多租户域名配置:通过实现DomainTenantResolveContributor,让系统从子域名自动识别租户,提升用户体验;支持自定义域名时需结合DNS解析和数据库映射。
  • 租户数据迁移:核心是“导出-导入-切换连接字符串”,需注意数据一致性和迁移后的验证,适合租户规模增长后的架构调整。
posted @ 2025-10-24 22:19  【唐】三三  阅读(6)  评论(0)    收藏  举报