使用 EF Core 开发多租户应用程序

目前大多数软件应用程序都是围绕多租户(Multi-tenancy)的概念构建的。一个应用程序为多个客户(租户)服务,同时保持他们的数据相互隔离。

实现多租户通常有两种主要方法:

  1. 单个数据库与租户的逻辑隔离
  2. 多个数据库与租户的物理隔离

选择哪种方案主要取决于你的业务需求。例如,医疗保健等行业对数据隔离有极高要求,因此通常必须为每个租户使用独立的数据库。那么,我们该如何使用 EF Core 来实现多租户支持呢?


基于单个数据库的多租户实现

要在单个数据库上实现多租户,你需要解决两件事:

  1. 识别当前租户:知道当前请求属于哪个租户。
  2. 数据过滤:确保只查询到属于该租户的数据。

典型的做法是在表中添加一个 TenantId 列,并在查询时通过该列进行过滤。我们可以在 DbContextOnModelCreating 方法中配置全局查询过滤器:

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)
{
// 从提供程序中获取当前租户 ID
_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 提供了强大的功能来支持这两种多租户实现方式。

参考资料