Localization Within Net Core

Description

Localization in ASP.Net Core is quite different to ASP.Net Framework, with Framework you would create a project file that defined a resource file (.resx) file for each culture you are supporting, you would then link to the project and reference the resource stings via strongly typed static strings which are automatically created when compiling the library.

Typically your reference within an aspx file would look similar to:

<%=Languages.LanguageStrings.AboutUs %>

When rendering the file to a browser, the most appropriate language would be selected from available resources and displayed to a user.

Controllers

.Net Core works in a slightly different way, resources are accessed via the IStringLocalizer and IStringLocalizer interfaces which you would inject into the Controller and View using dependency injection so that localized string could be obtained.

IStringLocalizer supports an indexer which is passed the key to the resource string. If the key is not found in a resource file, then that key is used as the string to display.

public class HomeController
{
    private readonly IStringLocalizer<HomeController> _localizer;
    public ExampleClass(IStringLocalizer<HomeControllerlocalizer)
    {
        _localizer = localizer;
    }
 
    public string GetEmailPrompt()
    {
        return _localizer["Please enter your email"];
    }
}

The snippet above would look for a localized string with a key name of 'Please enter your email', if it finds the localized resource then it will display it, otherwise it will display the string entered (the default language).

Views

Views use a similar approach to Controllers, you inject an IViewLocalizer instance into the view and obtain the localized string accordingly.

@using Microsoft.AspNetCore.Mvc.Localization
@model AddingLocalization.ViewModels.HomeViewModel
@inject IViewLocalizer Localizer
@{
    ViewData["Title"] = Localizer["Home Page"];
}
 
<h2>@ViewData["MyTitle"]</h2>

Data Annotations

Data annotations use an IStringLocalizer approach but work in exactly the same manner, the string "Please Enter Username or Email" is used as the key, if a localized string is found then that is used as the Required attribute, if not, the string displayed is used.

public sealed class LoginViewModel
{
    [Required(ErrorMessage = "Please Enter User name or Email"))]
    [Display(Name = "Username"))]
    public string Username { getset; }
}

Magic strings and other things

There are a couple of issues I have with this approach, the first being magic string, it is too easy for a typo to occur in the magic strings used, this could mean the key is never matched to a localized string, one of the other issues I have with this approach is using individual IStringLocalizer implementations. This prevents us from following the DRY principal where we would contain all strings in the same resource file and allow them to be reused.

It would also make translation easier for the translator as they would only have one file to work with instead of multiple resource files.

A further side benefit is that resource translations could be shared across multiple projects without any change, this could drastically reduce the cost of development.

Sharing Resources Translations

One of the beauties of .Net Core is that you can replace the default behavior by adding our own factory classes, step in IStringLocalizerFactory. This interface allows us to override the default behavior and gives us the ability to use a shared resource library. We also need to create our own instances of IStringLocalizer and IStringLocalizer.

ΙStringLocalizer

Our new IStringLocalizer class creates a static instance of the ResourceManager, this will obtain the string from the ResourceManager using the current threads CurrentUiCulture.

internal sealed class StringLocalizer : IStringLocalizer
{
    #region Private Members
 
    private static readonly ResourceManager _resourceManager = new ResourceManager("Languages.LanguageStrings", 
        typeof(LanguageStrings).Assembly);
 
    private static readonly Timings _timings = new Timings();
 
    #endregion Private Members
 
    #region Constructors
 
    public StringLocalizer()
    {
 
    }
 
 
    #endregion Constructors
 
    #region IStringLocalizer Methods
 
    public LocalizedString this[string name]
    {
        get
        {
            using (StopWatchTimer stopwatchTimer = StopWatchTimer.Initialise(_timings))
            {
                try
                {
                    StringBuilder resourceName = new StringBuilder(name.Length);
 
                    // strip out any non alpha numeric characters
                    foreach (char c in name)
                    {
                        if (c >= 65 && c <= 90)
                            resourceName.Append(c);
                        else if (c >= 61 && c <= 122)
                            resourceName.Append(c);
                        else if (c >= 48 && c <= 57)
                            resourceName.Append(c);
                    }
 
                    return new LocalizedString(name, _resourceManager.GetString(resourceName.ToString(), Thread.CurrentThread.CurrentUICulture));
                }
                catch (Exception error)
                {
                    Initialisation.GetLogger.AddToLog(Enums.LogLevel.Localization, errorname);
                    return new LocalizedString(namename);
                }
            }
        }
    }
 
    public LocalizedString this[string nameparams object[] arguments]
    {
        get
        {
            using (StopWatchTimer stopwatchTimer = StopWatchTimer.Initialise(_timings))
            {
                try
                {
                    StringBuilder resourceName = new StringBuilder(name.Length);
 
                    // strip out any non alpha numeric characters
                    foreach (char c in name)
                    {
                        if (c >= 65 && c <= 90)
                            resourceName.Append(c);
                        else if (c >= 61 && c <= 122)
                            resourceName.Append(c);
                        else if (c >= 48 && c <= 57)
                            resourceName.Append(c);
                    }
 
                    string resourceString = _resourceManager.GetString(resourceName.ToString(), Thread.CurrentThread.CurrentUICulture);
                    return new LocalizedString(nameString.Format(resourceStringarguments));
                }
                catch (Exception error)
                {
                    Initialisation.GetLogger.AddToLog(Enums.LogLevel.Localization, errorname);
                    return new LocalizedString(nameString.Format(namearguments));
                }
            }
        }
    }
 
    public IEnumerable<LocalizedStringGetAllStrings(bool includeParentCultures)
    {
        throw new NotImplementedException();
    }
 
    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
 
    #endregion IStringLocalizer Methods
}

IStringLocalizerFactory

Our implementation of IStringLocalizerFactory will create a new StringLocalizer, the type of resource i.e. IStringLocalizer is not used in the regular sense as we are now using a global resource.

internal sealed class StringLocalizerFactory : IStringLocalizerFactory
{
    #region IStringLocalizerFactory Methods
 
    public IStringLocalizer Create(Type resourceSource)
    {
        return (IStringLocalizer)cacheItem.Value;
    }
 
    public IStringLocalizer Create(string baseNamestring location)
    {
        return (IStringLocalizer)cacheItem.Value;
    }
 
    #endregion IStringLocalizerFactory Methods
}

Middleware

The middleware used natively already sets the current threads UI culture for us, so we can use that to obtain the correct culture to display to the user.

Configuration

Configuring your ASP.NET application to use remains the same.

public void ConfigureMvcBuilder(in IMvcBuilder mvcBuilder)
{
    mvcBuilder
        .AddViewLocalization(
            LanguageViewLocationExpanderFormat.Suffix,
            opts => { opts.ResourcesPath = "Resources"; })
        .AddDataAnnotationsLocalization();
}

Strongly Typed Resource Name

As I mentioned before, having "Magic Strings" is not the best, it can be broken by a simple typo. Now that we are using a shared resource project we can take advantage of the system generated static string generated for each resource. The only issue we have is that you can not add static strings within an attribute constructor. To overcome this we use the nameof language feature which obtains the name of a variable, type or member.

Strongly Typed Data Annotations

The previous LoginViewModel example used magic strings which were also the default string used to display the Required Message and Display names. We can now use strongly typed names using nameof, this ensures that the language string we want is there, otherwise the application will fail to compile.

[Required(ErrorMessage = nameof(Languages.LanguageStrings.PleaseEnterUserNameOrEmail))]
[Display(Name = nameof(Languages.LanguageStrings.Username))]
public string Username { getset; }
 
[Required(ErrorMessage = nameof(Languages.LanguageStrings.PleaseEnterPassword))]
[StringLength(Constants.MaximumPasswordLength, MinimumLength = Constants.MinimumPasswordLength)]
[Display(Name = nameof(Languages.LanguageStrings.Password))]
public string Password { getset; }
 
[Display(Name = nameof(Languages.LanguageStrings.Code))]
public string CaptchaText { getset; }

Strongly Typed View Localization Strings

In the following example we use a strongly typed string resource name with the existing IViewLocalizer.

@model LoginPlugin.Models.LoginViewModel
@inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer Localizer
@{
    ViewData["Title"] = "Index";
    var returnUrl = "/Account/CreateAccount";
 
    if (!String.IsNullOrEmpty(Model.ReturnUrl))
    {
        returnUrl += $"/?returnUrl={Model.ReturnUrl}";
    }
}
<div class="bc">@Html.Raw(Model.BreadcrumbData())</div>
 
<link rel="stylesheet" href="/css/login.css" />
 
<h2>@Localizer[nameof(Languages.LanguageStrings.Login)]</h2>

Strongly Typed Controllers

Within controllers we have two options, we can pass in an IStringLocalizer instance as we would before and reference the string using nameof, like above, or directly reference the static string created in the resource file.

ModelState.AddModelError(String.Empty, Languages.LanguageStrings.CodeNotValid);

Conclusion

By eliminating "Magic Strings" and using strongly typed resource names we ensure the application is free from logical Errors that can occur, replacing the IStringLocalizer and IStringLocalizerFactory we can enforce the use of single, shared library of localized strings, these can further be reused across multiple projects which decreases development costs and increases development time.