ABP - SaaS多租户(Multi-Tenancy)[ITenantRepository、ICurrentTenant、MultiTenantAttribute]
(1)SaaS多租户(Multi-Tenancy)
核心辅助类:
ITenantRepository:租户仓储。ICurrentTenant:当前租户信息。MultiTenantAttribute:标记多租户支持。
SaaS(软件即服务)多租户是指一个系统同时为多个租户(如不同公司、部门)提供服务,每个租户的数据相互隔离但共享同一套系统部署。ABP框架原生支持多租户,通过ITenantRepository、ICurrentTenant等工具实现租户管理和数据隔离,下面用通俗的例子讲解。
一、核心概念:什么是多租户?(生活例子)
想象一个写字楼:
- 整栋楼是“系统部署”(一套硬件和基础软件);
- 每个公司租的办公室是“租户”(独立空间,数据隔离);
- 办公室门牌号是“租户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的商品
TenantId为A的ID,租户B的商品TenantId为B的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通过以下方式识别当前租户(优先级从高到低):
- 请求头:
__tenant(如__tenant: baidu); - 查询参数:
?__tenant=baidu; - Cookie:
__tenant; - 域名:配置子域名映射(如
baidu.myapp.com对应租户baidu)。
前端调用示例(Axios设置租户头):
// 租户用户访问时,在请求头中携带租户ID
axios.interceptors.request.use(config => {
config.headers['__tenant'] = 'baidu'; // 租户标识(如租户ID或名称)
return config;
});
五、新手避坑指南
-
实体必须加
[MultiTenant]:否则ABP不会自动添加TenantId,导致数据无法隔离; -
主机与租户的区分:主机的
ICurrentTenant.Id为null,租户用户的Id为具体租户ID; -
共享数据库的索引优化:
TenantId字段建议加索引,提升查询性能(ABP自动创建); -
全局数据的处理:如果某些数据需要所有租户共享(如公共字典),实体不要加
[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解析和数据库映射。 - 租户数据迁移:核心是“导出-导入-切换连接字符串”,需注意数据一致性和迁移后的验证,适合租户规模增长后的架构调整。

浙公网安备 33010602011771号