While working on a project we were assigned a task to develop a localization solution for an existing webshop. The webshop had a lot of different text and it would be very time consuming defining all the keys and getting them translated in the three languages which were required. One of the requirements for the feature was to allow the addition of new languages as the product grew and different countries are added.
Getting started
After reading through the Microsoft docs it was decided that we would like to keep the built in features for localization which were available. Some of the features which we needed were string localization, view localization and DataAnnotation localization.
To start the implementation we first needed a way to define which languages are available inside the application. This process is well documented in the Microsoft docs under "Use a custom provider" section. As we wanted the cultures to be configurable we created an entity for this specific case:
public class Culture { public string Id { get; set; } public string Name { get; set; } }
To simplify the implementation we decided to use the culture identifier as the primary key for this table. With this we can seed the necessary cultures and also create an UI for dynamically adding new languages.
Registering languages
In order to register available languages we need to use the built in middleware. Using the service provider we can resolve the Repository (and/or database context) to query the available cultures. That looks something along the lines of the following code in Startup.cs:
var cultures = new List<Culture>(); using (var scope = app.ApplicationServices.CreateScope()) { var cultureRepository = scope.ServiceProvider.GetRequiredService<IRepository<Culture>>(); cultures = cultureRepository.Query().ToList(); } app.UseRequestLocalization(options => options .AddSupportedCultures(cultures ) .AddSupportedUICultures(cultures ) .SetDefaultCulture("en-US") );
Culture provider
After the cultures have been defined we needed to define the request culture provider; as we wanted the user to select his language once and on every subsequent visit have his language already selected. So naturally we added a foreign key to the user which defines the default culture. After this we implement the RequestCultureProvider and attempt to resolve the user inside of it and his default culture. If the user is not authenticated then we default to a sensible language.
public class EfRequestCultureProvider : RequestCultureProvider { public override async Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext) { // Your implementation of database context will of course vary var workContext = httpContext.RequestServices.GetRequiredService<IWorkContext>(); var user = await workContext.GetCurrentUser(); if (user == null) { // Predefined defaults if user not logged in var routeValues = httpContext.GetRouteData().Values; var routeCulture = routeValues["culture"] as string; if (!routeCulture.IsNullOrWhiteSpace()) { return new ProviderCultureResult(routeCulture); } return new ProviderCultureResult("en-US"); } return new ProviderCultureResult(user.DefaultCulture); } }
To use this provider we need to add it to the aforementioned UseRequestLocalization middleware options:
app.UseRequestLocalization(options => options .AddSupportedCultures(cultures ) .AddSupportedUICultures(cultures ) .SetDefaultCulture("en-US") .RequestCultureProviders.Insert(0, new EfRequestCultureProvider()) );
Defining translations
For now we have defined the cultures in the database, registered them to our middleware and defined a custom request culture provider. To connect everything we still need a way to define the required translations for the webshop. This bit is usually done through resource files (.resx). If we look at the structure of a single row in a resource file we can see it's constructed of three values: name, value and comment. As we did not see the added value benefit of the comment value we decided to redact it from our solution. So we created an entity that represents a row in a resource file, so we can transfer that logic to the database:
public class Resource { public long Id { get; set; } public string Key { get; set; } public string Value { get; set; } public string CultureId { get; set; } public Culture Culture { get; set; } }
The translations can of course be seeded if needed. We created UI and endpoints to allow CRUD operations for the localization resources, omitting here for brevity.
Loading the translations from the database
For the final solution to the puzzle we need to load the database translations to the service provider in a similar fashion as the resource file would be loaded. After doing some digging through the aspnetcore GitHub repository, we found the LocalizationServiceCollectionExtensions class. There we identified two interfaces which we would need for our implementation: IStringLocalizerFactory and the IStringLocalizer<>.
Looking at the IStringLocalizerFactory interface, it is clear we need to inherit from IStringLocalizer. So starting bottom up we went ahead and implemented the interface, all while keeping in mind that we want to avoid loading data from the database on each call so we also implemented a cache. The solution came out looking something like this:
public class EfStringLocalizer : IStringLocalizer { private readonly IServiceProvider _serviceProvider; private readonly IMemoryCache _resourcesCache; public EfStringLocalizer(IServiceProvider serviceProvider, IMemoryCache resourcesCache) { _serviceProvider = serviceProvider; _resourcesCache = resourcesCache; } public LocalizedString this[string name] { get { var value = GetString(name); return new LocalizedString(name, value ?? name, value == null); } } public LocalizedString this[string name, params object[] arguments] { get { var format = GetString(name); var value = string.Format(format ?? name, arguments); return new LocalizedString(name, value, format == null); } } public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) { var culture = CultureInfo.CurrentUICulture.Name; var resources = LoadResources(culture); return resources.Select(r => new LocalizedString(r.Key, r.Value, true)); } private string GetString(string name) { var culture = CultureInfo.CurrentUICulture.Name; var resources = LoadResources(culture); var value = resources.SingleOrDefault(r => r.Key == name)?.Value; return value; } private IList<Resource> LoadResources(string culture) { if (!_resourcesCache.TryGetValue(culture, out IList<Resource> resources)) { using (var scope = _serviceProvider.CreateScope()) { var resourceRepository = scope.ServiceProvider.GetRequiredService<IRepository<Resource>>(); resources = resourceRepository.Query().Where(r => r.Culture.Id == culture).ToList(); } _resourcesCache.Set(culture, resources); } return resources; } }
After this, implementing the factory was straightforward:
public class EfStringLocalizerFactory : IStringLocalizerFactory { private readonly IMemoryCache _resourcesCache; private readonly IServiceProvider _serviceProvider; public EfStringLocalizerFactory(IServiceProvider serviceProvider, IMemoryCache resourcesCache) { _serviceProvider = serviceProvider; _resourcesCache = resourcesCache; } public IStringLocalizer Create(Type resourceSource) { return new EfStringLocalizer(_serviceProvider, _resourcesCache); } public IStringLocalizer Create(string baseName, string location) { return new EfStringLocalizer(_serviceProvider, _resourcesCache); } }
Additional configuration and future
If we look back at the registered types in aspnetcore repository we can see that the IStringLocalizer<> generic is registered to the service collection. To prevent any confusion in the usage of this feature we decided to fulfill this interface as well, so it's backwards compatible with the resource lookup approach. The newly created generic version just uses the default EfStringLocalizer.
After all is said and done all we needed to do is register the types to the service provider:
services.TryAddSingleton<IStringLocalizerFactory, EfStringLocalizerFactory>(); services.TryAddTransient(typeof(IStringLocalizer<>), typeof(EfStringLocalizer<>));
While this approach is not perfect it allows us to keep the translations in the database while also keeping all the benefits of the built in framework localizations. Unfortunately at this time this solution requires restarting the application every time a localization change needs to be applied, some cache busting mechanism would be a good way to improve it.
Usage
To use the translations we can simply inject an IStringLocalizer<T> into a controller and then localize a string. The following snippet utilizes string formatting to insert placeholder values.
var errorMessage = _stringLocalizer["Item {0} isn't available.", item.Product.ComputedVariationName];
If using Razor, localization can be done inside the view. We inject IViewLocalizer to the _ViewImports.cshtml that makes it available in all views of an area. Sample of usage would look like this then:
<p>@Localizer["Are you sure you want to delete this item?"]</p>
Finishing with these usage samples we hope you find this blog post useful and even consider implementing this approach in one of your own projects.