- Localization Within Net Core
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
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<HomeController> localizer) { _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
public sealed class LoginViewModel { [Required(ErrorMessage = "Please Enter User name or Email"))] [Display(Name = "Username"))] public string Username { get; set; } }
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
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, error, name); return new LocalizedString(name, name); } } } } public LocalizedString this[string name, params 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(name, String.Format(resourceString, arguments)); } catch (Exception error) { Initialisation.GetLogger.AddToLog(Enums.LogLevel.Localization, error, name); return new LocalizedString(name, String.Format(name, arguments)); } } } } public IEnumerable<LocalizedString> GetAllStrings(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
internal sealed class StringLocalizerFactory : IStringLocalizerFactory { #region IStringLocalizerFactory Methods public IStringLocalizer Create(Type resourceSource) { return (IStringLocalizer)cacheItem.Value; } public IStringLocalizer Create(string baseName, string 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 { get; set; } [Required(ErrorMessage = nameof(Languages.LanguageStrings.PleaseEnterPassword))] [StringLength(Constants.MaximumPasswordLength, MinimumLength = Constants.MinimumPasswordLength)] [Display(Name = nameof(Languages.LanguageStrings.Password))] public string Password { get; set; } [Display(Name = nameof(Languages.LanguageStrings.Code))] public string CaptchaText { get; set; }
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.
In This Document
- Controllers
- Views
- Data Annotations
- Magic strings and other things
- Sharing Resources Translations
- ΙStringLocalizer
- IStringLocalizerFactory
- Middleware
- Configuration
- Strongly Typed Resource Name
- Strongly Typed Data Annotations
- Strongly Typed View Localization Strings
- Strongly Typed Controllers
- Conclusion