Migrating to .NET Core – Overridable Localization in Razor Class Libraries

I have to admit, the possibility I was most excited about when migrating to .NET Core Razor Pages projects from my 20 year old code base in WebForms was the use of Razor Class Libraries. Packaging up reusable UI components and their required resources into a single project sounded like bliss.

Previously, I had two problems that Razor Class Libraries were going to overcome.

  1. For shared ‘resources’ (css, js, images, etc.) I had a single Git repository that was nested under the main site projects and just included as regular source files. So, while I had (more or less) one code base, I had to make sure I pulled it every time I worked in a different site. I know there is some sort of feature with Git and nested or linked repositories, but I vaguely recall reading some articles about people complaining about them not functioning as advertised, so I steered clear.
  2. The other problem was UI components. Currently, I was creating all my shareable/core ‘controls/UI’ via C# and control creation inside a CreateChildControls method. While it was not horrible (after making some extension methods to make it a little more fluent), it definitely wasn’t as nice as working in HTML/Razor markup.

However, after getting my POC wired up, I came across this line of code in my old WebForms ascx:

<h1><%# GetString( "Default.lnkModeler" ) %></h1>

GetString was a helper method I wrote that wrapped multiple ResourceManager objects into a single instance and pulled the string from the ResourceManager with the highest precedence: Client Site > Infrastructure.UI > Services and so on.  Every level could have a resource file and no matter which level GetString was called, it would find the correct (possibly overridden) occurrence.  This allowed the client site to override any message it wanted to that came from the core (most common) implementation.

Implementing Overridable Localization

While learning ASP.NET Core, I figured I better do Localization ‘right’.  In my WebForms framework, each ‘level’ had only a single ResourceStrings.resx file.  However, in my Razor Page project, I wanted to leverage the LocalizationOptions.ResourcePath property and use the ViewLocalization class to find *.resx files matching my Razor Page names.  Ryan Brandenburg, of ASP.NET team, mentioned the following in a bug report talking about ViewLocalization not working in external assembly: 

We think the correct way to go about this would be to get a IStringLocalizer from some method internal to the library hosting the views, so:
@ExternalLibrary.GetLocalizer(viewPath) instead of @inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer Localizer. If it’s very important to you that this come from the IViewLocalizer for some reason you’ll have to write your own implementation.

Well, it was important to me, or at least important that I tried.  Your mileage may vary, but I was able to make ViewLocalizer, StringLocalizer<T>, and HtmlLocalizer<T> all behave the way I mentioned above.

The two problems to overcome were:

  1. Using the correct Assembly when trying to find the resource.  This is the main problem with ViewLocalizer injection used in views from Razor Class Libraries.  No matter which project the ViewLocalizer is injected in, it sets the Assembly to hostingEnvironment.ApplicationName.  When using a Razor Class Library (or any external library), the Assembly needs to be the assembly attempting to use ViewLocalizer.
  2. Overriding localization at any level.  Although this was a custom feature to our framework, it is basically the same pattern used in finding a Razor page from a Razor Class Library.  If the page is found in ‘same location’ in containing site, it uses that, otherwise it uses the page from the Razor Class Library.  To make this work, we simply required all localization points to contain a list of ‘localizers’ that it can try to find the desired string in instead of a single one from the containing site.

Using the Correct Assembly

For all my implementations, I simply introduced a LocalizationOptions that contained a string array of AssemblyNames that are configured during ConfigureServices defining all the assemblies to search.  In the example below, my ‘framework’ hierarchy is Client refs Template refs Infrastructure.

services.Configure<Infrastructure.Localization.LocalizationOptions>(
	options => options.AssemblyNames = new[] {
		hostingEnvironment.ApplicationName, // client
		new AssemblyName( typeof( Startup ).Assembly.FullName ).Name, // template
		new AssemblyName( typeof( Infrastructure.Localization.LocalizationOptions ).Assembly.FullName ).Name  // infrastructure
	}
);

Once the assembly names were in place, making the ViewLocalizer use the correct assembly wasn’t that bad.  I implemented the IViewContextAware.Contextualize method as the following (pretty much verbatim from ASP.NET Core ViewLocalizer implementation except for the assignment of htmlLocalizers which used each assembly name instead of the single hostingEnvironment.ApplicationName) :

public void Contextualize( ViewContext viewContext )
{
	if ( viewContext == null )
	{
		throw new ArgumentNullException( nameof( viewContext ) );
	}

	// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
	var path = viewContext.ExecutingFilePath;

	if ( string.IsNullOrEmpty( path ) )
	{
		path = viewContext.View.Path;
	}

	Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

	htmlLocalizers =
		localizationOptions.AssemblyNames
			.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
			.ToArray();
}

For StringLocalizer<T> and HtmlLocalizer<T> I had to steal some code from ResourceManagerStringLocalizerFactory. Both of these classes need to build a ‘localizer’ in their constructor based on TResourceSource. When TResourceSource is passed in, I need to get the class name (including any namespacing) but exclude the root namespace of the containing Assembly. This is where ResourceManagerStringLocalizerFactory.GetRootNamespace comes in. Here is my code to get the class ‘name’ excluding the assembly namespace.

protected string BuildBaseSuffix( Type resourceType )
{
	// Given a type of RootNameSpace.NS1.NS2.Class,
	// The return value will be N1.N2.Class and should be used to create localizers 
	// for each assembly passed in on `LocalizationOptions`.  
	//
	// The caller will then need to call
	//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
	//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
	//		...
	//
	//
	// factory.Create will correctly put in the 'ResourcePath' based on the
	// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
	// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

	var typeInfo = resourceType.GetTypeInfo();
	var rootNamespace = GetRootNamespace( typeInfo.Assembly );
	return typeInfo.FullName.Substring( rootNamespace.Length + 1 );
}

private string GetRootNamespace( Assembly assembly )
{
	var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

	if ( rootNamespaceAttribute != null )
	{
		return rootNamespaceAttribute.RootNamespace;
	}

	return assembly.GetName().Name;
}

Then, similar to the ViewLocalizer, inside the constructors of both these generic classes, their localizers are created via appending each assembly name to the base suffix name:

var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

localizers =
	assemblyNames
		.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
		.ToArray();

Applying Localizer Precedence

Now that my localizers were initialized properly, returning the requested string is pretty straightforward. I just loop through the list of localizers until I get a hit, otherwise return the last attempted LocalizedString – where the value will just default to the key/name passed in.

protected LocalizedString GetLocalizedString( IStringLocalizer[] stringLocalizers, string key, object[] arguments )
{
	if ( key == null )
	{
		throw new ArgumentNullException( nameof( key ) );
	}

	var ls = stringLocalizers[ 0 ][ key, arguments ];
	var currentLocalizer = 1;

	while ( ls.ResourceNotFound && currentLocalizer < stringLocalizers.Length )
	{
		ls = stringLocalizers[ currentLocalizer ][ key, arguments ];
		currentLocalizer++;
	}

	return ls;
}

Performance Concerns

I am using as much of the code from the framework as possible, so don’t think I will be causing any additional overhead except for obviously loading a localizer for each assembly name passed in. However, I was a bit surprised that the internal localizer cache inside ResourceManagerStringLocalizerFactory used different methods of generating a cache key based on Create( Type ) vs Create( string, string ) method calls. That said, assuming the overhead of attempting to find a resource, and not finding a match, isn’t too expensive (i.e. when the client assembly doesn’t override the requested string), this should be a straight forward implementation to allow you to use ViewLocalizer in Razor Class Library projects mimicking the same pattern of a client Page/Partial overriding a library Page/Partial.

Dependency Injection Requirements

In addition to the built-in UseLocalization and UseViewLocalization (or UseMvcLocalization which I am not using…yet), you need to use the newly created classes.

services.TryAddTransient( typeof( IStringLocalizer<> ), typeof( Infrastructure.Localization.StringLocalizer<> ) );
services.TryAddTransient( typeof( IHtmlLocalizer<> ), typeof( Infrastructure.Localization.HtmlLocalizer<> ) );
services.TryAddTransient( typeof( IViewLocalizer ), typeof( Infrastructure.Localization.ViewLocalizer ) );

Below you will find my final source code.  I wanted to keep all the ‘weird’ logic in one file, so all my classes derive from a single ResourceLocalizer.  Unfortunately, with no shared interfaces or base classes between StringLocalizer and HtmlLocalizer I couldn’t think of a clever way to keep the code completely DRY.  I have identical code for GetLocalizedString and GetAllStrings except for the parameter type passed in.

Drop a comment if you have any concerns, improvements, or questions…I’m still learning.

public class ResourceLocalizer
{
	protected string BuildBaseSuffix( Type resourceType )
	{
		// Given a type of RootNameSpace.NS1.NS2.Class,
		// The return value will be N1.N2.Class and should be used to create localizers 
		// for each assembly passed in on `LocalizationOptions`.  
		//
		// The caller will then need to call
		//		factory.Create( Assembly1.N1.N2.Class, Assembly1);
		//		factory.Create( Assembly2.N1.N2.Class, Assembly2);
		//		...
		//
		//
		// factory.Create will correctly put in the 'ResourcePath' based on the
		// `ResourceLocationAttribute` of each assembly.  Otherwise it assumes that all
		// assemblies have the same resource path set inside `LocalizationOptions.ResourcesPath`.

		var typeInfo = resourceType.GetTypeInfo();
		var rootNamespace = GetRootNamespace( typeInfo.Assembly );
		return typeInfo.FullName.Substring( rootNamespace.Length + 1 );
	}

	private string GetRootNamespace( Assembly assembly )
	{
		var rootNamespaceAttribute = assembly.GetCustomAttribute<RootNamespaceAttribute>();

		if ( rootNamespaceAttribute != null )
		{
			return rootNamespaceAttribute.RootNamespace;
		}

		return assembly.GetName().Name;
	}

	protected string BuildBaseName( string path, string applicationName )
	{
		var extension = Path.GetExtension( path );
		var startIndex = path[ 0 ] == '/' || path[ 0 ] == '\\' ? 1 : 0;
		var length = path.Length - startIndex - extension.Length;
		var capacity = length + applicationName.Length + 1;
		var builder = new StringBuilder( path, startIndex, length, capacity );

		builder.Replace( '/', '.' ).Replace( '\\', '.' );

		// Prepend the application name
		builder.Insert( 0, '.' );
		builder.Insert( 0, applicationName );

		return builder.ToString();
	}

	protected IEnumerable<LocalizedString> GetAllStrings( IHtmlLocalizer[] htmlLocalizers, bool includeParentCultures )
	{
		var keys = new HashSet<string>();

		foreach ( var l in htmlLocalizers )
		{
			foreach ( var s in l.GetAllStrings( includeParentCultures ) )
			{
				if ( !keys.Contains( s.Name ) )
				{
					yield return s;
				}
			}
		}
	}

	protected LocalizedHtmlString GetLocalizedHtmlString( IHtmlLocalizer[] htmlLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var lhs = htmlLocalizers[ 0 ][ key, arguments ];
		var currentLocalizer = 1;

		while ( lhs.IsResourceNotFound && currentLocalizer < htmlLocalizers.Length )
		{
			lhs = htmlLocalizers[ currentLocalizer ][ key, arguments ];
			currentLocalizer++;
		}

		return lhs;
	}

	protected LocalizedString GetLocalizedString( IHtmlLocalizer[] htmlLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var ls = htmlLocalizers[ 0 ].GetString( key, arguments );
		var currentLocalizer = 1;

		while ( ls.ResourceNotFound && currentLocalizer < htmlLocalizers.Length )
		{
			ls = htmlLocalizers[ currentLocalizer ].GetString( key, arguments );
			currentLocalizer++;
		}

		return ls;
	}

	protected IEnumerable<LocalizedString> GetAllStrings( IStringLocalizer[] htmlLocalizers, bool includeParentCultures )
	{
		var keys = new HashSet<string>();

		foreach ( var l in htmlLocalizers )
		{
			foreach ( var s in l.GetAllStrings( includeParentCultures ) )
			{
				if ( !keys.Contains( s.Name ) )
				{
					yield return s;
				}
			}
		}
	}

	protected LocalizedString GetLocalizedString( IStringLocalizer[] stringLocalizers, string key, object[] arguments )
	{
		if ( key == null )
		{
			throw new ArgumentNullException( nameof( key ) );
		}

		var ls = stringLocalizers[ 0 ][ key, arguments ];
		var currentLocalizer = 1;

		while ( ls.ResourceNotFound && currentLocalizer < stringLocalizers.Length )
		{
			ls = stringLocalizers[ currentLocalizer ][ key, arguments ];
			currentLocalizer++;
		}

		return ls;
	}
}

public class ViewLocalizer : ResourceLocalizer, IViewLocalizer, IViewContextAware
{
	private readonly IHtmlLocalizerFactory localizerFactory;
	private readonly LocalizationOptions localizationOptions;
	private IHtmlLocalizer[] htmlLocalizers;

	public ViewLocalizer( IHtmlLocalizerFactory localizerFactory, IOptions<LocalizationOptions> localizationOptions )
	{
		if ( localizerFactory == null )
		{
			throw new ArgumentNullException( nameof( localizerFactory ) );
		}

		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		this.localizationOptions = localizationOptions.Value;
		this.localizerFactory = localizerFactory;
	}

	public void Contextualize( ViewContext viewContext )
	{
		if ( viewContext == null )
		{
			throw new ArgumentNullException( nameof( viewContext ) );
		}

		// Given a view path "/Views/Home/Index.cshtml" we want a baseName like "MyApplication.Views.Home.Index"
		var path = viewContext.ExecutingFilePath;

		if ( string.IsNullOrEmpty( path ) )
		{
			path = viewContext.View.Path;
		}

		Debug.Assert( !string.IsNullOrEmpty( path ), "Couldn't determine a path for the view" );

		htmlLocalizers =
			localizationOptions.AssemblyNames
				.Select( a => localizerFactory.Create( BuildBaseName( path, a ), a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string key ] => GetLocalizedHtmlString( htmlLocalizers, key, new object[] { } );
	public virtual LocalizedHtmlString this[ string key, params object[] arguments ] => GetLocalizedHtmlString( htmlLocalizers, key, arguments );

	public LocalizedString GetString( string name ) => GetLocalizedString( htmlLocalizers, name, new object[] { } );
	public LocalizedString GetString( string name, params object[] values ) => GetLocalizedString( htmlLocalizers, name, values );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( htmlLocalizers, includeParentCultures );
}

public class StringLocalizer<TResourceSource> : ResourceLocalizer, IStringLocalizer<TResourceSource>
{
	private IStringLocalizer[] localizers;

	public StringLocalizer(
		IStringLocalizerFactory factory,
		IOptions<LocalizationOptions> localizationOptions )
	{
		if ( factory == null )
		{
			throw new ArgumentNullException( nameof( factory ) );
		}
		if ( localizationOptions == null )
		{
			throw new ArgumentNullException( nameof( localizationOptions ) );
		}

		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		localizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedString this[ string name ] => GetLocalizedString( localizers, name, new object[] { } );
	public virtual LocalizedString this[ string name, params object[] arguments ] => GetLocalizedString( localizers, name, arguments );

	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( localizers, includeParentCultures );

	public IStringLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
}

public class HtmlLocalizer<TResourceSource> : ResourceLocalizer, IHtmlLocalizer<TResourceSource>
{
	private IHtmlLocalizer[] htmlLocalizers;

	public HtmlLocalizer(
		IHtmlLocalizerFactory factory,
		IOptions<LocalizationOptions> localizationOptions )
	{
		var baseNameSuffix = BuildBaseSuffix( typeof( TResourceSource ) );

		htmlLocalizers =
			localizationOptions.Value.AssemblyNames
				.Select( a => factory.Create( $"{a}.{baseNameSuffix}", a ) )
				.ToArray();
	}

	public virtual LocalizedHtmlString this[ string name ] => GetLocalizedHtmlString( htmlLocalizers, name, new object[] { } );
	public virtual LocalizedHtmlString this[ string name, params object[] arguments ] => GetLocalizedHtmlString( htmlLocalizers, name, arguments );

	public LocalizedString GetString( string name ) => GetLocalizedString( htmlLocalizers, name, new object[] { } );
	public LocalizedString GetString( string name, params object[] values ) => GetLocalizedString( htmlLocalizers, name, values );

	public IHtmlLocalizer WithCulture( CultureInfo culture ) => throw new NotImplementedException();
	public IEnumerable<LocalizedString> GetAllStrings( bool includeParentCultures ) => GetAllStrings( htmlLocalizers, includeParentCultures );
}

7 thoughts on “Migrating to .NET Core – Overridable Localization in Razor Class Libraries

  1. I have an issue getting StringLocalizer to work in a class library project.
    Your solution looks quite comprehensive – do you have a sample project available anywhere?

  2. I was looking for the same solution and it works perfectly! Thanks for sharing! Do you have this logic in a nuget package? If not, do you plan to create one?

    1. No and no, lol. I have yet to create a nuget package personally. Hopefully the source code will suffice for you. If you make a package, let me know.

  3. small flaw. Every where there is “!keys.Contains(s.Name)” should probably changed to “keys.Add(s.Name)” or use a custom IEqualityComparer and use Linq Distinct.

Leave a comment