目前大多数软件应用程序都是围绕多租户(Multi-tenancy)的概念构建的。一个应用程序为多个客户(租户)服务,同时保持他们的数据相互隔离。
实现多租户通常有两种主要方法:
- 单个数据库与租户的逻辑隔离
- 多个数据库与租户的物理隔离
选择哪种方案主要取决于你的业务需求。例如,医疗保健等行业对数据隔离有极高要求,因此通常必须为每个租户使用独立的数据库。那么,我们该如何使用 EF Core 来实现多租户支持呢?
基于单个数据库的多租户实现
要在单个数据库上实现多租户,你需要解决两件事:
- 识别当前租户:知道当前请求属于哪个租户。
- 数据过滤:确保只查询到属于该租户的数据。
典型的做法是在表中添加一个 TenantId 列,并在查询时通过该列进行过滤。我们可以在 DbContext 的 OnModelCreating 方法中配置全局查询过滤器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class OrdersDbContext : DbContext { private readonly string _tenantId;
public OrdersDbContext( DbContextOptions<OrdersDbContext> options, TenantProvider tenantProvider) : base(options) { _tenantId = tenantProvider.TenantId; }
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .Entity<Order>() .HasQueryFilter(o => o.TenantId == _tenantId); } }
|
查询过滤器是 EF Core 提供的一项强大功能,允许你为实体类型定义全局过滤条件。这样,每次查询该实体时,EF Core 都会自动应用这些过滤条件。
- 通过在实体上调用
HasQueryFilter 来配置查询过滤器。 - EF Core 会将该过滤器应用于该实体的所有查询。
- 你可以使用
IgnoreQueryFilters 手动关闭它。 - 每个实体仅允许定义一个查询过滤器。
在上面的代码中,我们通过 TenantProvider 获取当前租户的 ID,并在查询过滤器中使用它来确保只返回属于该租户的订单。其实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public sealed class TenantProvider { private const string TenantIdHeaderName = "X-TenantId"; private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; }
public string TenantId => _httpContextAccessor .HttpContext .Request .Headers[TenantIdHeaderName]; }
|
在此示例中,TenantId 是从 HTTP 请求头中获取的。其他常见的获取方式包括:
- 查询字符串:
api/orders?tenantId=xxx - JWT 声明(Claim)
- API 密钥
为了安全性,建议优先使用 JWT 声明 或 API 密钥。
使用JWT声明的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public sealed class TenantProvider { private const string TenantIdClaimName = "tenant_id"; private readonly IHttpContextAccessor _httpContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; }
public string TenantId => _httpContextAccessor .HttpContext .User .FindFirst(TenantIdClaimName) ?.Value; }
|
基于独立数据库的多租户实现
如果我们希望将每个租户隔离在不同的数据库中,该怎么办?
这种情况下,我们需要做出以下调整:
- 为每个租户应用不同的连接字符串。
- 实现解析特定租户连接字符串的机制。
在这种模式下不能使用查询过滤器,因为数据分布在不同的物理数据库中。你需要将租户信息及其连接字符串存储在某个地方。
一个简单的实现方案是将其存储在应用配置中:
1 2 3 4 5 6 7 8 9 10 11
| "Tenants": [ { "Id": "tenant-1", "ConnectionString": "Host=tenant1.db;Database=tenant1" }, { "Id": "tenant-2", "ConnectionString": "Host=tenant2.db;Database=tenant2" } ]
|
然后,我们可以修改 TenantProvider 以返回当前租户的连接字符串:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| public sealed class TenantProvider { private const string TenantIdHeaderName = "X-TenantId"; private readonly IHttpContextAccessor _httpContextAccessor; private readonly TenantSettings _tenantSettings;
public TenantProvider( IHttpContextAccessor httpContextAccessor, IOptions<TenantSettings> tenantsOptions) { _httpContextAccessor = httpContextAccessor; _tenantSettings = tenantsOptions.Value; }
public string TenantId => _httpContextAccessor .HttpContext .Request .Headers[TenantIdHeaderName];
public string GetConnectionString() { return _tenantSettings.Tenants.Single(t => t.Id == TenantId).ConnectionString; } }
|
最后一步是在注册 DbContext 时,动态解析连接字符串:
1 2 3 4 5 6 7 8
| builder.Services.AddDbContext<OrdersDbContext>((sp, o) => { var tenantProvider = sp.GetRequiredService<TenantProvider>(); var connectionString = tenantProvider.GetConnectionString(); o.UseSqlServer(connectionString); });
|
这样,在每次请求时,系统都会创建一个新的 OrdersDbContext 并连接到该租户对应的数据库。
注:在生产环境中,应考虑将租户连接字符串存储在安全的地方,如 Azure Key Vault。
总结
希望本文能帮助你更好地理解如何使用 EF Core 构建多租户系统。无论是选择单一数据库还是多个数据库的方案,都需要根据具体的业务需求和数据隔离要求来决定。EF Core 提供了强大的功能来支持这两种多租户实现方式。
参考资料