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.
- 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.
- 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:
- Using the correct
Assembly
when trying to find the resource. This is the main problem withViewLocalizer
injection used in views from Razor Class Libraries. No matter which project theViewLocalizer
is injected in, it sets theAssembly
tohostingEnvironment.ApplicationName
. When using a Razor Class Library (or any external library), theAssembly
needs to be the assembly attempting to useViewLocalizer
. - 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 );
}
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?
Figured it out, works well thanks.
Sorry, comment notifications were going to old email. What was problem and solution?
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?
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.
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.