多租户应用中基于用户邮箱加载租户密钥库的实现方案咨询
原方案的可行性分析
直接在Web应用构建阶段(WebApplication.CreateBuilder时期)通过用户邮箱获取Key Vault并加载配置不可行。因为这个阶段属于应用启动初始化流程,还未处理任何HTTP请求,无法访问HttpContext获取当前登录用户的邮箱信息,也就无法调用外部目录服务拿到对应租户的Key Vault信息。
替代实现方案
核心思路是延迟资源初始化,在请求处理阶段根据当前租户信息动态获取连接串并创建资源客户端,而非在启动阶段一次性加载所有租户配置。以下是具体组件的实现方式:
1. 租户标识的传递与获取
先通过中间件提取当前请求的用户邮箱(从JWT Token或认证信息中),将租户信息存入HttpContext.Items,供后续流程使用:
app.Use(async (context, next) => { // 从认证信息中获取用户邮箱,根据实际认证方式调整 var userEmail = context.User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; if (!string.IsNullOrEmpty(userEmail)) { // 调用目录服务获取租户对应的Key Vault名称 var keyVaultName = await GetKeyVaultFromExternalAPI(userEmail, context.Request.Headers["Authorization"]); context.Items["TenantKeyVaultName"] = keyVaultName; context.Items["TenantEmail"] = userEmail; } await next(); });
2. 数据库(DbContext)的动态创建
使用自定义工厂类,在每个请求中根据租户Key Vault获取连接串,创建专属的DbContext实例:
public interface ITenantDbContextFactory { Task<AppDbContext> CreateDbContextAsync(); } public class TenantDbContextFactory : ITenantDbContextFactory { private readonly IHttpContextAccessor _httpContextAccessor; public TenantDbContextFactory(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public async Task<AppDbContext> CreateDbContextAsync() { var keyVaultName = _httpContextAccessor.HttpContext?.Items["TenantKeyVaultName"] as string; if (string.IsNullOrEmpty(keyVaultName)) { throw new InvalidOperationException("无法获取租户Key Vault信息"); } // 从Key Vault获取数据库连接串 var kvClient = new SecretClient(new Uri($"https://{keyVaultName}.vault.azure.net"), new DefaultAzureCredential()); var connectionStringSecret = await kvClient.GetSecretAsync("DatabaseConnectionString"); var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>(); optionsBuilder.UseSqlServer(connectionStringSecret.Value.Value); return new AppDbContext(optionsBuilder.Options); } } // 注册服务 builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped<ITenantDbContextFactory, TenantDbContextFactory>();
在业务逻辑中使用工厂创建DbContext:
public class SomeService { private readonly ITenantDbContextFactory _dbContextFactory; public SomeService(ITenantDbContextFactory dbContextFactory) { _dbContextFactory = dbContextFactory; } public async Task DoWork() { using var dbContext = await _dbContextFactory.CreateDbContextAsync(); // 执行业务操作 } }
3. Redis的动态客户端创建
避免在启动阶段注册单例的IDistributedCache,而是用工厂类按需创建ConnectionMultiplexer:
public class TenantRedisClientFactory { private readonly IHttpContextAccessor _httpContextAccessor; public TenantRedisClientFactory(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public async Task<IConnectionMultiplexer> CreateRedisClientAsync() { var keyVaultName = _httpContextAccessor.HttpContext?.Items["TenantKeyVaultName"] as string; if (string.IsNullOrEmpty(keyVaultName)) { throw new InvalidOperationException("无法获取租户Key Vault信息"); } var kvClient = new SecretClient(new Uri($"https://{keyVaultName}.vault.azure.net"), new DefaultAzureCredential()); var redisConnString = await kvClient.GetSecretAsync("RedisConnectionString"); return await ConnectionMultiplexer.ConnectAsync(redisConnString.Value.Value); } } // 注册服务 builder.Services.AddScoped<TenantRedisClientFactory>();
使用时从工厂获取客户端:
public class CacheService { private readonly TenantRedisClientFactory _redisFactory; public CacheService(TenantRedisClientFactory redisFactory) { _redisFactory = redisFactory; } public async Task SetCacheValueAsync(string key, string value) { var redis = await _redisFactory.CreateRedisClientAsync(); var db = redis.GetDatabase(); await db.StringSetAsync(key, value); } }
4. Blob存储的动态客户端创建
类似Redis,用工厂类按需创建BlobServiceClient:
public class TenantBlobClientFactory { private readonly IHttpContextAccessor _httpContextAccessor; public TenantBlobClientFactory(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public async Task<BlobServiceClient> CreateBlobServiceClientAsync() { var keyVaultName = _httpContextAccessor.HttpContext?.Items["TenantKeyVaultName"] as string; if (string.IsNullOrEmpty(keyVaultName)) { throw new InvalidOperationException("无法获取租户Key Vault信息"); } var kvClient = new SecretClient(new Uri($"https://{keyVaultName}.vault.azure.net"), new DefaultAzureCredential()); var blobConnString = await kvClient.GetSecretAsync("BlobStorageConnectionString"); return new BlobServiceClient(blobConnString.Value.Value); } } builder.Services.AddScoped<TenantBlobClientFactory>();
5. Application Insights的多租户适配
Application Insights通常在启动阶段配置,但可以通过TelemetryInitializer动态设置租户对应的Instrumentation Key:
public class TenantTelemetryInitializer : ITelemetryInitializer { private readonly IHttpContextAccessor _httpContextAccessor; // 缓存已获取的Instrumentation Key,避免重复调用Key Vault private static readonly Dictionary<string, string> _instrumentationKeyCache = new(); public TenantTelemetryInitializer(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public void Initialize(ITelemetry telemetry) { var keyVaultName = _httpContextAccessor.HttpContext?.Items["TenantKeyVaultName"] as string; if (string.IsNullOrEmpty(keyVaultName)) return; if (!_instrumentationKeyCache.TryGetValue(keyVaultName, out var instrumentationKey)) { var kvClient = new SecretClient(new Uri($"https://{keyVaultName}.vault.azure.net"), new DefaultAzureCredential()); instrumentationKey = kvClient.GetSecretAsync("AppInsightsInstrumentationKey").GetAwaiter().GetResult().Value.Value; _instrumentationKeyCache[keyVaultName] = instrumentationKey; } telemetry.Context.InstrumentationKey = instrumentationKey; // 添加租户标识作为自定义维度,方便筛选 telemetry.Context.Properties["TenantEmail"] = _httpContextAccessor.HttpContext?.Items["TenantEmail"] as string; } } // 注册服务 builder.Services.AddApplicationInsightsTelemetry(); builder.Services.AddSingleton<ITelemetryInitializer, TenantTelemetryInitializer>();
关于中间件更新配置的疑问
中间件无法直接更新已注册服务的配置(比如Redis、数据库的连接串),因为这些服务在启动阶段已经完成初始化(多为单例模式)。中间件的作用是传递租户标识,真正的资源配置加载和客户端创建需要通过工厂模式在请求处理阶段动态完成,这样才能保证每个请求使用对应租户的资源。
内容的提问来源于stack exchange,提问作者akb

