2019年4月8日 星期一

[ASP.NET Core] API for Localization strings


 Vue.js   vue-18n   ASP.NET Core  


Introduction­


We will use the following stack for localization:
1.  ASP.NET Core resource files
2.  Vue-i18n

This article shows how to localize with resource files in ASP.NET Core Web API project.



Environment


.NET Core 2.2.104
Visual  Studio 2017 Community



Implement


Use the resource files in the same project

When we define the ResourcesPath to "Resources" as following in Startup.cs,

public void ConfigureServices(IServiceCollection services)
{
   services.AddLocalization(options => options.ResourcesPath = "Resources");
};

the default structure should be related to the namespace of Controllers (or Views):

./Resources/Controllers.HomeController.en-US.resx
Or
./Resources/Controllers/HomeController.en-US.resx

For example,





Enable the global localization on every requests in Startup.Configure method,

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
     app.UseRequestLocalization();
}


Thus we can use the injected localizer and use it like this, 

[ApiController]
public partial class HomeController : ControllerBase
{
        private readonly IStringLocalizer<HomeController> _localizer;

        public LocaleController(
            IStringLocalizer<HomeController> localizer)
        {
            this._localizer = localizer;
            var localizedStr = this._localizer["Name"];
       }
}


However in most case we would like to put the resource files in other project…

Use the resource files in other/original project

First we have to use a non-specified ResourcesPath in Starup.cs


public void ConfigureServices(IServiceCollection services)
{
   services.AddLocalization();
};

And create a new empty class at other/current project to make ASP.NET Core can catch the namespace and its own resource files.

For example, create a ShareResource.cs and ShareResource.<lang>.resx 




namespace My.Other.Project.Resources
{
    public class ShareResource
    {
    }
}


Then we can inject the localizer with dictionaries from the above resource files as following in the target ASP.NET Core project,

using My.Other.Project.Resources;

[ApiController]
public partial class LocaleController : ControllerBase
{
        private readonly IStringLocalizer _shareLocalizer;

        public HomeController(
            IStringLocalizer<ShareResource> shareLocalizer)
        {
           this._shareLocalizer = shareLocalizer;
        }
}  


Create a Localization API

Let’s create the localization API which supports localization route.
More specific, a url like “http://localhost/api/Locale/Get/en-US” will make the request’s culture as “en-US”. And a url “http://localhost/api/Locale/Get/unknown” shall fallback it to the default culture, such as “zh-TW” in this tutorial.



Startup.cs

public class Startup {
        public Startup (IConfiguration configuration) {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices (IServiceCollection services) {
            services.AddLocalization();
            // …
        }
        public void Configure (IApplicationBuilder app) {

            // Localization
            app.UseRequestLocalization();
            // …
            app.UseMvc ();
        }
    }


PS. We can set the custom RequestLocalizationOptions as following,

 var supportedCultures = new[]
 {
    new CultureInfo("zh-TW"),
    new CultureInfo("zh-CN"),                   
    new CultureInfo("en-US"),
 };

 app.UseRequestLocalization (new RequestLocalizationOptions {
    DefaultRequestCulture = new RequestCulture ("zh-TW"),
    // Formatting numbers, dates, etc.
    SupportedCultures = supportedCultures,
    // UI strings that we have localized.
    SupportedUICultures = supportedCultures
 });

Notice the DefaultRequestCulture will be used for requests when a supported culture could not be determined by Microsoft.AspNetCore.Localization.IRequestCultureProviders.
And the default value will be set to both:


Furthermore, we will apply the localization configuration to ONLY one API in the next steps, so it’s suggested to create a middleware class and only apply it to the target Controller or Actions.

Localization Middleware

public class LocalizationMiddleware
{
        private readonly string defaultCulture = "zh-TW";

        public static CultureInfo[] SupportedCultures = new[]{
            new CultureInfo("zh-TW"),
            new CultureInfo("zh-CN"),
            new CultureInfo("en-US"),
         };

        public void Configure(IApplicationBuilder app)
        {
            app.UseRequestLocalization(new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture(defaultCulture),
                // Formatting numbers, dates, etc.
                SupportedCultures = SupportedCultures,
                // UI strings that we have localized.
                SupportedUICultures = SupportedCultures
            });
        }
}


Don’t forget to remove the global localization pipeline in Startup.Configure method,

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
     // app.UseRequestLocalization();
}





LocalizedString extension

Lets create an Extension for LocalizedString to generate JSON string from IEnumerable<LocalizedString>.

public static class LocalizedStringExtension
{
        public static async Task<string> ToJsonStringAsync(this IEnumerable<LocalizedString> source, bool isCamelLowerCaseForKey, string prefixKey="")
        {
            string json = string.Empty;
            var dicsTask = localizedStrsToDictionary(source);
            JsonSerializerSettings camelCaseFormatter = null;

            if (isCamelLowerCaseForKey)
            {
                camelCaseFormatter = new JsonSerializerSettings();
                camelCaseFormatter.ContractResolver = new CamelCasePropertyNamesContractResolver();
            }

            var dics = await dicsTask;
            json = camelCaseFormatter == null ?
                JsonConvert.SerializeObject(dics) :
                JsonConvert.SerializeObject(dics, camelCaseFormatter);

            if (!string.IsNullOrEmpty(prefixKey))
            {
                json = string.Concat("{\"",prefixKey,"\":", json, "}");
            }

            return json;
        }
}



ApiController

We will set a route parameter to determine which language (resource file) to use.

[ApiController]
public partial class LocaleController : ControllerBase
{     
   private readonly IStringLocalizer _shareLocalizer;
   public LocaleController(
       IStringLocalizer<ShareResource> shareLocalizer)
   {
            this._shareLocalizer = shareLocalizer;
   }

   [MiddlewareFilter(typeof(LocalizationMiddleware))]
   [Route("Get/{locale}")]
   [HttpGet]
   public async Task<string> Get([FromRoute] string locale)
   {
         IEnumerable<LocalizedString> localizedStrs = null;
         Thread.CurrentThread.CurrentUICulture = cultureInfo;
         Thread.CurrentThread.CurrentCulture = cultureInfo;
         localizedStrs = this._shareLocalizer.GetAllStrings(includeParentCultures: true);
         return await localizedStrs.ToJsonStringAsync(isCamelLowerCaseForKey: true);
   }
}




Notice that if we don’t set the value to Thread.CurrentThread.CurrentCulture/ CurrentUICulture, the default culture will be set the value of DefaultRequestCulture.


We can rewrite the above codes by creating a custom localizer,

var cultureInfo = new CultureInfo(locale);
var customLocalizer = this._shareLocalizer.WithCulture(cultureInfo);
localizedStrs = customLocalizer.GetAllStrings(includeParentCultures: true);

                          


Result


 




However if we set a non-supported* culture (CultureInfo) to the localizer or System.CurrentThread.CurrentUICulture/CurrentCulture, we will get Server Error when getting the localized string(s) as following,

PS. Non-supported means we don’t create the mapping resource file for it.




Why not the localizer just returns the localized strings with DefaultRequestCulture when no resource files found… lol

Anyway we have to deal with this case by checking the supported cultures(languages).
Update the API as this,

[MiddlewareFilter(typeof(LocalizationMiddleware))]
[Route("Get/{locale}")]
[HttpGet]
public async Task<string> Get([FromRoute] string locale)
{
       IEnumerable<LocalizedString> localizedStrs = null;
       IStringLocalizer customLocalizer = null;
       if (LocalizationMiddleware.SupportedCultures.Any(x => x.Name.Equals(locale)))
       {
           var cultureInfo = new CultureInfo(locale);
           customLocalizer = this._shareLocalizer.WithCulture(cultureInfo);
           localizedStrs = customLocalizer.GetAllStrings(includeParentCultures: true);
       }
       else
       {
           localizedStrs = this._shareLocalizer.GetAllStrings(includeParentCultures: true);
       }
       
       return await localizedStrs.ToJsonStringAsync(isCamelLowerCaseForKey: true);
}



Then the non-supported culture will fall back to DefaultRequestCulture now!






(Optional) Custom localization provider

RequestLocalizationOptions provides three default culture providers that are automatically configured. (List in order, the first matched one will determine the request’s culture)


We can create a custom localization provider and put it into RequestLocalizations. RequestCultureProviders with new orders as following in Startup.ConfigureServices:

Take the following codes for example, we set MyCustomCultureProvider as the first-priority rule:

services.Configure<RequestLocalizationOptions>(options =>
{
    //...skip
    options.RequestCultureProviders.Insert(0, new MyCustomCultureProvider());
});
services.AddLocalization();


Now we can create a custom provider: RouteCultureProvider, to determine the request’s culture from the route.

This new custom RequestCultureProvider won’t change the user’s behavior, i.e. request url.
However, it will replace the logic for culture-determination inside the API. 



RouteCultureProvider

public class RouteCultureProvider : RequestCultureProvider
{
    private const string defaultCulture = "zh-TW";
    public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
    {
       object finalCulture = string.Empty;
       if (httpContext == null)
       {
          throw new ArgumentNullException(nameof(httpContext));
       }
       try
       {
          using (var routeMatcher = new RouteMatcher())
          {
             PathString path = httpContext.Request.Path;
             var template = "api/Locale/Get/{locale}";
             var routeValues = routeMatcher.Matches(template, path.Value);
             routeValues.TryGetValue("locale", out finalCulture);
           }
        }
        catch (Exception)
        { finalCulture = defaultCulture; }
        finally
{ finalCulture = finalCulture ?? defaultCulture; }

        return Task.FromResult(new ProviderCultureResult(finalCulture as string));
}

Here I created RouteMatcher to parse the request’s url and get the value of “locale”.

RouteMatcher

public class RouteMatcher : IDisposable
{
        /// Match the request path with route template
        public RouteValueDictionary Matches(string routeTemplate, string requestPath)
        {
            var template = TemplateParser.Parse(routeTemplate);
            var matcher = new TemplateMatcher(template, getDefaults(template));
            var values = new RouteValueDictionary();
            var moduleMatch = matcher.TryMatch(requestPath, values);
            return values;
        }
       
// Extracts the default argument values from the template
        private RouteValueDictionary getDefaults(RouteTemplate parsedTemplate)
        {
            var result = new RouteValueDictionary();

            foreach (var parameter in parsedTemplate.Parameters)
            {
                if (parameter.DefaultValue != null)
                {
                    result.Add(parameter.Name, parameter.DefaultValue);
                }
            }

            return result;
        }
}



Startup.cs

Update Startup.ConfigureServices as following.
Notice that I clear all default culture providers and set RouteCultureProvider as the only one.

public void ConfigureServices(IServiceCollection services)
{
    var supportedCultures = new CultureInfo[] {
                new CultureInfo("zh-TW"),
                new CultureInfo("zh-CN"),
                new CultureInfo("en-US"),
    };
    services.Configure<RequestLocalizationOptions>(options =>
    {
         options.DefaultRequestCulture = new RequestCulture("zh-TW");
         options.SupportedCultures = supportedCultures;
         options.SupportedUICultures = supportedCultures;
         options.RequestCultureProviders.Clear();
         options.RequestCultureProviders.Add(new RouteCultureProvider());
     });
     services.AddLocalization();
}


Enable the global localization pipeline in Startup.Configure,

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseRequestLocalization();
}


Then we can simply the API with the custom culture provider.

ApiController

[Route("Get/{locale}")]
[HttpGet]
public async Task<string> Get([FromRoute] string locale)
{
   var localizedStrs = this._localizer.GetAllStrings(includeParentCultures: true);
   return await localizedStrs.ToJsonStringAsync(isCamelLowerCaseForKey: true);
}





(Optional) Custom localization provider: Final version

To optimize the codes, we can move the RequestLocalizationOptions configuration from Startup.ConfigureServices to the Middleware class: LocalizationMiddleware.


LocalizationMiddleware.cs

public class LocalizationMiddleware
 {
        private readonly string defaultCulture = "zh-TW";

        public static CultureInfo[] SupportedCultures = new[]{
            new CultureInfo("zh-TW"),
            new CultureInfo("zh-CN"),
            new CultureInfo("en-US"),
         };

        public void Configure(IApplicationBuilder app)
        {
            var options = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture(defaultCulture),
                // Formatting numbers, dates, etc.
                SupportedCultures = SupportedCultures,
                // UI strings that we have localized.
                SupportedUICultures = SupportedCultures
                //Clear default providers
            };
            options.RequestCultureProviders.Clear();
            options.RequestCultureProviders.Add(new RouteCultureProvider());

            app.UseRequestLocalization(options);
        }
 }


And remove the global localization pipeline and update ApiController,

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    //This line is no longer needed cus we use the LocalizationMiddleware
    //app.UseRequestLocalization();
}

ApiController

[Route("Get/{locale}")]
[HttpGet]
[MiddlewareFilter(typeof(LocalizationMiddleware))]
public async Task<string> Get([FromRoute] string locale)
{
    var localizedStrs = this._localizer.GetAllStrings(includeParentCultures: true);
    return await localizedStrs.ToJsonStringAsync(isCamelLowerCaseForKey: true);
}


                          


Result(GIF)





Reference




沒有留言:

張貼留言