HIMEM.SYS

Inheritable string localizers for ASP.NET Core

2019-01-06 07:10:25 +0000 ·

Introduction

ASP.NET Core provides the new type of IStringLocalizer<T> to deal with string resources and localization. There has been written a lot about this already and a simple search will provide you with all background information you need - so I’ll skip this here, only talking about the basic bits where they are required to what is done and why.

Usually, you would use a IStringLocalizer<T> where T is a type (say MyService) and then also provide a .resx file of the same name, including the culture name for the translations (e.g. MyService.de-DE.resx). Note that you specifically, don’t need to provide a resource file for the default language of your application.

Let’s look at the following example to make this more clear:

public class MyService : IMyService 
{
    private readonly IStringLocalizer<MyService> m_strings;

    public MyService(IStringLocalizer<MyService> strings)
    {
        m_strings = strings;
    }

    public string GetResult()
    {
        return m_strings["The results"];
    }
}

In the above you can see a couple of things:

  • The IStringLocalizer<MyService> is usually provided by DI.
  • You get a string from the resources by using the index-operator. The text you pass is used as the key into the resource file for the “current” culture. If it doesn’t exist in the resource file, the text passed is returned.

What’s the problem then?

For the following, there is one important thing to keep in mind: You cannot create instances of IStringLocalizer<T> directly; you are expected to have them injected.

That doesn’t sound too bad, but consider the following example:

internal static class Helpers
{
    // Helper method called by multiple implementations.
    internal static string DoTheFoo(IStringLocalizer strings)
    {
        return strings["The foo was done."];
    }
}

So you have an helper method (static to make the case obvious that DI is possible) that is used by multiple other services or code.

Where would you put the resources for this? You could put them into IStringLocalizer<SharedResources>, but simply defining an empty class called SharedResources and SharedResources.<culture>.resx that goes with it.

However, that doesn’t scale too well in bigger projects:

  • You end up with a “God”-resources sink that is probably not a good thing
  • You need to pass/inject multiple IStringLocalizer<T> into other classes
  • You need to know all the IStringLocalizer<T> you need to inject to satisfy all code that a service may call downstream.

Especially the last point breaks encapsulation and is plain cumbersome to maintain.

For example, consider this:

public class MyService : IMyService 
{
    public MyService(
        IStringLocalizer<MyService> strings, // for the service itself
        IStringLocalizer<IOHelpers> ioStrings, // for the IOHelpers tools
        IStringLocalizer<SharedResources> sharedStrings // generic stuff
        )
    {
        // elided for brevity
    }
}

A possible solution

Overview

A possible solution to this is to only inject the on IStringLocalizer<T> into a service, the one that is directly “bound” to it. But then, have that localizer name dependencies that should also be available.

For exempale:

[InheritStringLocalization(typeof(SharedResources))]
[InheritStringLocalization(typeof(IOHelpers))]
public class MyServiceResources
{
}

// --

public class MyService : IMyService 
{
    public MyService(IStringLocalizer<MyServiceResources> strings)
    {
        // elided for brevity
    }
}

OK, you still have to note all the dependencies here, but at least not on the constructor directly. Additionally, if you define a proper resource hierarchy for your project you can make things a little more obvious.

For example, in the project where I employed this technique basically all code was structured like this:

  • Controllers
    • Services
      • Utility classes

Each level could have resources so that by default every controller resource would only have InheritStringLocalization-attributes for the services it uses, and every service resource would only have them for the utility classes it uses. In other words, each level only has explicit dependencies to the immediate next level. Something which is not possible if you use the plain DI-approach that the framework provides.

How does it work

The InheritStringLocalization-attribute basically says that when attempting to resolve a resource from the type it is applied on, the type specified by the attribute should also be consider, if the resource is not found in the type itself. It does that down the chain. So if the type specified by the attribute does not contain the resource, but has an InheritStringLocalization-attribute itself, this type is searched, and so on.

[InheritStringLocalization(typeof(CheckAfterResourceType))]
public class ResourceType { }

[InheritStringLocalization(typeof(CheckAfterResourceType2))]
public class CheckAfterResourceType { }

public class CheckAfterResourceType2 { }

If multiple attributes are applied to a resource type, the first that contains the resource is used. The search order can be influenced by an optional Priority-parameter for the attribute:

[InheritStringLocalization(typeof(SharedResources), Priority=2)]
[InheritStringLocalization(typeof(IOHelpers), Priority=1)]
public class MyServiceResources
{
}

Lower priority values are considered first.

Finally note, that using this approach one can create a resource type that serves merely as a bundle, but does not define any resources itself:

// No SystemResources.*.resx exists
[InheritStringLocalization(typeof(IOSystemResources))]
[InheritStringLocalization(typeof(UISystemResources))]
public class SystemResources { }

// IOSystemResources.*.resx exists
public class IOSystemResources { }

// UISystemResources.*.resx exists
public class UISystemResources { }

Implementation

InheritStringLocalizationAttribute

This is the attribute that is applied to resource types to indicate that they should “inherit” resources from the specified “InheritFrom” type.

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class InheritStringLocalizationAttribute : Attribute
{
    public InheritStringLocalizationAttribute(Type inheritFrom)
    {
        InheritFrom = inheritFrom ?? throw new ArgumentNullException(nameof(inheritFrom));
    }
    public Type InheritFrom { get; }
    public int Priority { get; set; }
}

MultiStringLocalizer

This is the implementation behind an IStringLocalizer<T> that uses a T that has at least InheritStringLocalizationAttribute applied.

public class MultiStringLocalizer : IStringLocalizer
{
    private readonly List<IStringLocalizer> m_localizers;
    private readonly ILogger<MultiStringLocalizer> m_logger;
    private readonly CultureInfo m_cultureInfo;

    public MultiStringLocalizer(List<IStringLocalizer> localizers, ILogger<MultiStringLocalizer> logger)
    {
        if (localizers == null)
            throw new ArgumentNullException(nameof(localizers));
        if (localizers.Count == 0)
            throw new ArgumentException("Empty not supported", nameof(localizers));
        m_localizers = localizers;
        m_logger = logger ?? NullLogger<MultiStringLocalizer>.Instance;
    }

    private MultiStringLocalizer(List<IStringLocalizer> localizers, ILogger<MultiStringLocalizer> logger, CultureInfo cultureInfo)
        : this(localizers.Select(l => l.WithCulture(cultureInfo)).ToList(), logger)
    {
        m_cultureInfo = cultureInfo;
    }

    public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        var result = new Dictionary<string, LocalizedString>();
        foreach (var localizer in m_localizers)
        {
            foreach (var entry in localizer.GetAllStrings(includeParentCultures))
            {
                if (!result.ContainsKey(entry.Name))
                {
                    result.Add(entry.Name, entry);
                }
            }
        }

        return result.Values;
    }

    public IStringLocalizer WithCulture(CultureInfo culture)
    {
        if (culture == null)
        {
            return new MultiStringLocalizer(m_localizers.ToList(), m_logger);
        }

        return new MultiStringLocalizer(m_localizers, m_logger, culture);
    }

    private void OnLogAttempt(IStringLocalizer localizer, LocalizedString result, CultureInfo cultureInfo)
    {
        var keyCulture = cultureInfo ?? CultureInfo.CurrentUICulture;
        if (!result.ResourceNotFound)
        {
            m_logger.LogDebug($"{localizer.GetType()} found '{result.Name}' in '{result.SearchedLocation}' with culture '{keyCulture}'");
        }
        else
        {
            m_logger.LogDebug($"{localizer.GetType()} searched for '{result.Name}' in '{result.SearchedLocation}' with culture '{keyCulture}'");
        }
    }

    public LocalizedString this[string name]
    {
        get
        {
            LocalizedString s = null;
            foreach (var localizer in m_localizers)
            {
                s = localizer[name];
                if (!s.ResourceNotFound)
                {
                    OnLogAttempt(localizer, s, m_cultureInfo);
                    return s;
                }

                OnLogAttempt(localizer, s, m_cultureInfo);
            }
            Debug.Assert(s != null);
            return s;
        }
    }

    public LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            LocalizedString s = null;
            foreach (var localizer in m_localizers)
            {
                s = localizer[name, arguments];
                if (!s.ResourceNotFound)
                {
                    OnLogAttempt(localizer, s, m_cultureInfo);
                    return s;
                }

                OnLogAttempt(localizer, s, m_cultureInfo);
            }
            Debug.Assert(s != null);
            return s;
        }
    }
}

The implementation is rather straight forward. Simply iterate all passed localizers until one returns the resource in question.

InheritStringLocalizerFactory

This class is responsible for actually creating our MultiStringLocalizer types. It needs to be registered with DI, which can be done with code like this:

public static class LocalizerExtensions
{
    public static IServiceCollection AddInheritStringLocalizerFactory(this IServiceCollection services, Action<LocalizationOptions> action = null)
    {
        if (services == null)
        {
            throw new ArgumentNullException(nameof(services));
        }

        services.AddOptions();
        services.AddSingleton<IStringLocalizerFactory, InheritStringLocalizerFactory>();
        services.AddTransient(typeof (IStringLocalizer<>), typeof (StringLocalizer<>));
        if (action != null)
        {
            services.Configure(action);
        }

        return services;
    }
}

The actual factory implementation looks like this:

public class InheritStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly ConcurrentDictionary<Type, IStringLocalizer> m_cache = new ConcurrentDictionary<Type, IStringLocalizer>();
    private readonly ResourceManagerStringLocalizerFactory m_factory;
    private readonly ILoggerFactory m_loggerFactory;

    public InheritStringLocalizerFactory(IOptions<LocalizationOptions> localizationOptions, ILoggerFactory loggerFactory)
    {
        m_factory = new ResourceManagerStringLocalizerFactory(localizationOptions, loggerFactory);
        m_loggerFactory = loggerFactory;
    }

    public IStringLocalizer Create(Type resourceSource)
    {
        if (resourceSource == null)
            throw new ArgumentNullException(nameof(resourceSource));

        return CreateStringLocalizer(resourceSource);
    }

    private IStringLocalizer CreateStringLocalizer(Type type)
    {
        return m_cache.GetOrAdd(type, CreateStringLocalizerDirect);
    }

    private IStringLocalizer CreateStringLocalizerDirect(Type t)
    {
        var attributes = t.GetCustomAttributes<InheritStringLocalizationAttribute>();
        if (attributes.Any())
        {
            var localizers = new List<IStringLocalizer>();
            var localizer = m_factory.Create(t);
            localizers.Add(localizer);
            foreach (var attribute in attributes.OrderBy(a => a.Priority))
            {
                localizer = CreateStringLocalizer(attribute.InheritFrom);
                localizers.Add(localizer);
            }

            return new MultiStringLocalizer(localizers, m_loggerFactory.CreateLogger<MultiStringLocalizer>());
        }

        return m_factory.Create(t);
    }

    public IStringLocalizer Create(string baseName, string location) => m_factory.Create(baseName, location);
}








  • About
  • Contact
  • Search
  • Powered by Jekyll and based on the Trio theme