受欢迎的博客标签

ASP.NET Core localization middleware with JSON resource files

Published

source code github

I’m working on a ASP.NET Core  project that requires localization of the UI. Damien Bod does a very good job of explaining how to do this using good old resx files. However, I’m writing the entire front-end with Visual Studio Code and resx somehow didn’t seem like a good match. It’s a clunky XML format that requires a header containing an XML schema definition and although .NET Core supports the format, Visual Studio Code lacks any support (at least none that I could find).

So I thought, why not store resources in JSON files? My initial requirements are text resources only so a simple key-value format should do the trick. JSON seems an obvious match. For comparison, I have a (Dutch) JSON resource file first:

{
   "ResourceKey.Welcome": "Welkom"
}

And the corresponding resx file for expressing ‘the same’ information. This comparison is of course not entirely fair but when you need just a simple key-value mapping, the resx format is a bit bloated to say the least…

<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                        xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" 
                             msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms</value>
  </resheader>
  <data name="ResourceKey.Welcome" xml:space="preserve">
    <value>Welkom</value>
  </data>
</root>

Did you find my key and value entirely at the bottom of the file? The rest of the file is metadata.

So, what does a localization middleware component look like that reads its resources from JSON files? By the way, all the code for this post can be found in this GitHub repository (still very much in beta at the moment of writing). And here’s a good explanation of doing something similar but then from a database.

Configuration


First, the configuration. This happens in the ConfigureServices method:

public void ConfigureServices(IServiceCollection services)
{
    // Add localization based on JSON files.
    services.AddJsonLocalization(options => options.ResourcesPath = "Resources");

    // Add MVC service and view localization.
    services
        .AddMvc()
        .AddViewLocalization();
}

The call to AddJsonLocalization installs the required localization services, which we will discuss next. At the moment, it has one configuration parameter: ResourcesPath to specify where to look for the JSON resource files.

The framework-supported AddViewLocalization installs an html-safe wrapper around our localization services and an IViewLocationExpander that selects views based on current culture: LanguageViewLocationExpander. For example, it can generate the view name Views/Home/nl/Action when you’re in Holland, pretty cool.

Middleware

Middleware configuration begins with the AddJsonLocalization call which is an extension method for IServicesCollection.

using System;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Localization;

namespace Microsoft.Extensions.DependencyInjection
{
  using global::Localization.JsonLocalizer;
  using global::Localization.JsonLocalizer.StringLocalizer;

  public static class JsonLocalizationServiceCollectionExtensions
  {
    public static IServiceCollection AddJsonLocalization(
        this IServiceCollection services)
    {
      return AddJsonLocalization(services, setupAction: null);
    }

    public static IServiceCollection AddJsonLocalization(
        this IServiceCollection services,
        Action<JsonLocalizationOptions> setupAction)
    {
      services.TryAdd(new ServiceDescriptor(typeof(IStringLocalizerFactory),
          typeof(JsonStringLocalizerFactory), ServiceLifetime.Singleton));
      services.TryAdd(new ServiceDescriptor(typeof(IStringLocalizer),
          typeof(JsonStringLocalizer), ServiceLifetime.Singleton));

      if (setupAction != null)
      {
        services.Configure(setupAction);
      }
      return services;
    }
  }
}

The AddJsonLocalization method basically adds two additional singleton services: JsonStringLocalizerFactory and JsonStringLocalizer. JsonStringLocalizerFactory is an implementation of IStringLocalizerFactory and this interface provides two factory methods:

public interface IStringLocalizerFactory
{
  IStringLocalizer Create(Type resourceSource);
  IStringLocalizer Create(string baseName, string location);
}

These correspond to the two usage patterns for localizers. The first is for injection into classes, a controller class for example:

public class HomeController : Controller
{
  public HomeController(IHtmlLocalizer<HomeController> localizer)
  {
    var welcomeText = localizer["ResourceKey.Welcome"];
  }
}

The second is called when a localizer is injected directly into a view:

@inject IViewLocalizer Localizer
<span>@Localizer["ResourceKey.Welcome"], Ronald</span>

Suppose the view is Views/Home/Index.cshtml and your application is located in a folder called My.Application then the second IStringLocalizerFactory method is called with parameters (baseName: "Views.Home.Index.cshtml", location: "My.Application").

Resource location algorithm

I’m not going into details on the JsonStringLocalizerFactory and JsonStringLocalizer classes themselves because the code is on GitHub so you can check it out there. What’s more interesting is the algorithm that looks for resource files. If you want to actually use this middleware that may be more useful, I think.

Suppose we inject a IHtmlLocalizer<HomeController> into a My.Application.HomeController class. Suppose furthermore we are in Holland so the culture is nl-NL and we have set the JsonLocalizationOptions.ResourcesPath to "Resources". The algorithm will look for a JSON resource file with the following paths in order:

My.Application.HomeController.nl-NL.json
My/Application.HomeController.nl-NL.json
My/Application/HomeController.nl-NL.json
Resources.HomeController.nl-NL.json
Resources/HomeController.nl-NL.json
My.Application.HomeController.nl.json
My/Application.HomeController.nl.json
My/Application/HomeController.nl.json
Resources.HomeController.nl.json
Resources/HomeController.nl.json
My.Application.HomeController.json
My/Application.HomeController.json
My/Application/HomeController.json
Resources.HomeController.json
Resources/HomeController.json

So the algorithm starts with the most specific culture and falls back to less specific cultures. This ‘looking for resource files’ operation is relatively expensive so the result is cached for later use and guaranteed to execute just once.

 

System.Text.Json to save the file system

using System.Text.Json;
using System.IO;

// Data to be written to the file system, and read back later
List<Customer> customers = ...;

// Serialize and save
string fileName = ...;
var serializedData = JsonSerializer.Serialize(customers);
File.WriteAllText(fileName, serializedData);
...

// Read and deserialize
var rawData = File.ReadAllText(fileName);
customers = JsonSerializer.Deserialize<List<Customer>>(rawData);

 

Useful links

example

https://github.com/blogifierdotnet/Blogifier/blob/master/src/Blogifier/Resources/localization.json