ASP.NET Core多租户应用本地化资源存储方案问询
Nice question! This is a super common requirement for multi-tenant ASP.NET Core apps, and with a bit of customization to the localization pipeline, you can make this work smoothly. Let's walk through a practical implementation that ties tenant-specific database resources to the built-in localization system:
The idea is to combine two layers of localization:
- Default Base Resources: Store your fallback text in standard
.resxfiles (embedded in your app) for all tenants. - Tenant-Specific Overrides: Let each tenant customize text by storing their modified resources in their own dedicated database.
- The localization system will first check the tenant's database for a resource key; if it doesn't exist, it falls back to the default
.resxvalues.
1. Tenant Context & Resolution
First, you need to identify which tenant is making the request (based on the host name) and inject their context into the request pipeline. This will let you access their database connection string later.
Define a Tenant Context Interface
public interface ITenantContext { string TenantId { get; } string DbConnectionString { get; } bool IsTenantIdentified { get; } } public class TenantContext : ITenantContext { public string TenantId { get; set; } = string.Empty; public string DbConnectionString { get; set; } = string.Empty; public bool IsTenantIdentified { get; set; } }
Tenant Resolution Middleware
Add a middleware to parse the request host and load the tenant's details (from a shared configuration database or app settings):
public class TenantResolutionMiddleware { private readonly RequestDelegate _next; public TenantResolutionMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync(HttpContext context, ITenantStore tenantStore) { var tenantContext = context.RequestServices.GetRequiredService<ITenantContext>(); var host = context.Request.Host.Host; // Fetch tenant from shared store (e.g., a central database table for tenants) var tenant = await tenantStore.GetTenantByHostnameAsync(host); if (tenant != null) { tenantContext.TenantId = tenant.Id; tenantContext.DbConnectionString = tenant.DbConnectionString; tenantContext.IsTenantIdentified = true; } await _next(context); } } // Register the middleware in Program.cs app.UseMiddleware<TenantResolutionMiddleware>();
Don't forget to register ITenantContext as a scoped service in your DI container:
builder.Services.AddScoped<ITenantContext, TenantContext>();
2. Custom String Localizer for Tenant Databases
Implement IStringLocalizer to pull resources from the tenant's database, with fallback to default .resx files.
Database Resource Model
First, define a model for your tenant's localization resources table (create this table in each tenant's database):
public class TenantLocalizationResource { public string TenantId { get; set; } = string.Empty; public string ResourceKey { get; set; } = string.Empty; public string Culture { get; set; } = string.Empty; public string ResourceValue { get; set; } = string.Empty; // Composite primary key: TenantId + ResourceKey + Culture }
Tenant DB String Localizer Implementation
public class TenantDbStringLocalizer : IStringLocalizer { private readonly ITenantContext _tenantContext; private readonly IStringLocalizer _defaultLocalizer; private readonly IDbConnectionFactory _dbConnectionFactory; public TenantDbStringLocalizer( ITenantContext tenantContext, IStringLocalizerFactory localizerFactory, IDbConnectionFactory dbConnectionFactory) { _tenantContext = tenantContext; _defaultLocalizer = localizerFactory.Create(typeof(SharedResources)); // Your default resource class _dbConnectionFactory = dbConnectionFactory; } public LocalizedString this[string name] { get { // Skip DB lookup if no tenant is identified if (!_tenantContext.IsTenantIdentified) return _defaultLocalizer[name]; // Try to get tenant-specific resource var tenantValue = GetTenantResourceValue(name, CultureInfo.CurrentUICulture.Name); if (!string.IsNullOrEmpty(tenantValue)) return new LocalizedString(name, tenantValue, resourceNotFound: false); // Fallback to default resource return _defaultLocalizer[name]; } } public LocalizedString this[string name, params object[] arguments] { get { var baseString = this[name]; return baseString.ResourceNotFound ? _defaultLocalizer[name, arguments] : new LocalizedString(name, string.Format(baseString.Value, arguments), false); } } public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) { var tenantStrings = new List<LocalizedString>(); if (_tenantContext.IsTenantIdentified) { tenantStrings = GetAllTenantResources(includeParentCultures).ToList(); } // Merge with default strings, keeping tenant overrides var defaultStrings = _defaultLocalizer.GetAllStrings(includeParentCultures); var mergedStrings = tenantStrings.Concat( defaultStrings.Where(d => !tenantStrings.Any(t => t.Name == d.Name))); return mergedStrings; } // Helper to fetch a single resource from tenant DB private string? GetTenantResourceValue(string key, string culture) { using var connection = _dbConnectionFactory.CreateConnection(_tenantContext.DbConnectionString); connection.Open(); return connection.QueryFirstOrDefault<string>( @"SELECT ResourceValue FROM TenantLocalizationResources WHERE TenantId = @TenantId AND ResourceKey = @Key AND Culture = @Culture", new { _tenantContext.TenantId, Key = key, Culture = culture }); } // Helper to fetch all resources for the tenant (including parent cultures if needed) private IEnumerable<LocalizedString> GetAllTenantResources(bool includeParentCultures) { using var connection = _dbConnectionFactory.CreateConnection(_tenantContext.DbConnectionString); connection.Open(); var culture = CultureInfo.CurrentUICulture; var culturesToCheck = includeParentCultures ? culture.GetCultures(CultureTypes.SpecificCultures | CultureTypes.NeutralCultures) .TakeWhile(c => c != CultureInfo.InvariantCulture) .Select(c => c.Name) : new[] { culture.Name }; var resources = connection.Query<TenantLocalizationResource>( @"SELECT ResourceKey, ResourceValue, Culture FROM TenantLocalizationResources WHERE TenantId = @TenantId AND Culture IN @Cultures", new { _tenantContext.TenantId, Cultures = culturesToCheck }); // Return the most specific culture first return resources .OrderByDescending(r => r.Culture.Length) .Select(r => new LocalizedString(r.ResourceKey, r.ResourceValue, false)); } // Implement WithCulture (required for IStringLocalizer) public IStringLocalizer WithCulture(CultureInfo culture) { // Create a new instance with the specified culture (you can optimize this with caching) return new TenantDbStringLocalizer( _tenantContext, new StringLocalizerFactory(), // Or inject your factory _dbConnectionFactory); } }
3. Register Custom Localization Services
Replace the default string localizer with your custom one in Program.cs:
// Add base localization services builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); // Register your custom localizer builder.Services.AddScoped<IStringLocalizer>(sp => new TenantDbStringLocalizer( sp.GetRequiredService<ITenantContext>(), sp.GetRequiredService<IStringLocalizerFactory>(), sp.GetRequiredService<IDbConnectionFactory>())); // Configure request localization builder.Services.Configure<RequestLocalizationOptions>(options => { var supportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("zh-CN"), // Add your supported cultures here }; options.DefaultRequestCulture = new RequestCulture("en-US"); options.SupportedCultures = supportedCultures; options.SupportedUICultures = supportedCultures; }); // Enable request localization middleware app.UseRequestLocalization();
4. Initialize Tenant Resources
When creating a new tenant, populate their database with the default .resx values so they have a starting point to customize. Build an initializer service:
public class TenantResourceInitializer { private readonly IStringLocalizer _defaultLocalizer; private readonly IDbConnectionFactory _dbConnectionFactory; public TenantResourceInitializer(IStringLocalizer defaultLocalizer, IDbConnectionFactory dbConnectionFactory) { _defaultLocalizer = defaultLocalizer; _dbConnectionFactory = dbConnectionFactory; } public async Task InitializeTenantResourcesAsync(string tenantId, string dbConnectionString, IEnumerable<CultureInfo> supportedCultures) { using var connection = _dbConnectionFactory.CreateConnection(dbConnectionString); await connection.OpenAsync(); foreach (var culture in supportedCultures) { var defaultResources = _defaultLocalizer.WithCulture(culture).GetAllStrings(true); foreach (var resource in defaultResources) { // Insert or update to avoid duplicates await connection.ExecuteAsync( @"INSERT INTO TenantLocalizationResources (TenantId, ResourceKey, Culture, ResourceValue) VALUES (@TenantId, @Key, @Culture, @Value) ON DUPLICATE KEY UPDATE ResourceValue = @Value", new { TenantId = tenantId, Key = resource.Name, Culture = culture.Name, Value = resource.Value }); } } } }
Call this service when you provision a new tenant (e.g., in your tenant creation API endpoint).
5. Add Tenant Resource Management UI
Build a simple interface for tenants to edit their resource values. All you need to do is let them update the ResourceValue column in their TenantLocalizationResources table—no app restarts required, since the localizer reads from the database in real-time.
- Cache Resources: Reading from the database on every request can hit performance. Add a distributed cache (like Redis) that caches resources by
TenantId + Culture + ResourceKey. Invalidate the cache when a tenant updates a resource. - Connection Pooling: Ensure your database connection factory uses proper connection pooling to avoid overhead.
- Error Handling: Add fallback logic if the tenant's database is unavailable (e.g., revert to default resources and log the error).
- Parent Culture Support: The implementation above includes parent culture fallback (e.g.,
zh-CN→zh→en-US) which is important for broad localization coverage.
内容的提问来源于stack exchange,提问作者John Mathison

