Reputation: 1313
I have web app written in ASP.NET Core v2.1. The app uses localized views configured via LocalizationOptions.ResourcesPath = "Resources"
and the access to localized strings is via injected IViewLocalizer
in cshtml files.
In some conditions I'd like to render the view with the different resource file than the default one located in Resources
folder. The other resource file have the same keys as the default one (no need to change the view) so only the different texts will be rendered.
E.g. consider such action method in controller and see the comments what I'd like to solve:
public async Task<IActionResult> ShowSomething([FromQuery] bool useDifferentResource)
{
if (useDifferentResource)
{
// how to render the view which will use different resource file
// then the default found in Resources folder?
// return View("MyView");
}
// this renders the view with the default resource file found in Resources folder
return View("MyView");
}
Upvotes: 2
Views: 3101
Reputation: 63317
Firstly I'm not so sure about the necessity of your requirement. But by digging into the source code, I've found it possible and come up with a solution here.
Actually the IViewLocalizer
is instantiated by IHtmlLocalizerFactory
which is registered as a singleton. That depends on IStringLocalizerFactory
which is also registered as a singleton. Here we use localization by resources file (managed by ResourceManager
) so the implementation class is ResourceManagerStringLocalizerFactory
.
That factory consumes the options LocalizationOptions
to get the configured ResourcesPath
used for creating the instance of IStringLocalizer
which is wrapped by HtmlLocalizer
and finally wrapped in ViewLocalizer
. The point here is the result is cached by a cache key depending on the view/page path & the name of the assembly (in which the resources are embedded). So right after first time creating the instance of ViewLocalizer
(available via DI), it will be cached and you have no chance to change the configured ResourcesPath
or intercept to change it somehow.
That means we need a custom ResourceManagerStringLocalizerFactory
to override the Create
method (actually it's not virtual, but we can re-implement it). We need to include one more factor (the runtime resources path) to the cache key so that caching will work correctly. Also there is one virtual method in ResourceManagerStringLocalizerFactory
that can be overridden to provide your runtime resources path: GetResourceLocationAttribute
. To minimize the implementation code for the custom ResourceManagerStringLocalizerFactory
, I choose that method to override. By reading the source code, you can see that it's not the only point to intercept to provide your own runtime resources path but it seems to be the easiest.
That's the core principle. However when it comes to the implementation for the full solution, it's not that simple. Here's the full code:
/// <summary>
/// A ViewLocalizer that can be aware of the request feature IActiveViewLocalizerFeature to use instead of
/// basing on the default implementation of ViewLocalizer
/// </summary>
public class ActiveLocalizerAwareViewLocalizer : ViewLocalizer
{
readonly IHttpContextAccessor _httpContextAccessor;
public ActiveLocalizerAwareViewLocalizer(IHtmlLocalizerFactory localizerFactory, IHostingEnvironment hostingEnvironment,
IHttpContextAccessor httpContextAccessor) : base(localizerFactory, hostingEnvironment)
{
_httpContextAccessor = httpContextAccessor;
}
public override LocalizedHtmlString this[string key, params object[] arguments]
{
get
{
var localizer = _getActiveLocalizer();
return localizer == null ? base[key, arguments] : localizer[key, arguments];
}
}
public override LocalizedHtmlString this[string key]
{
get
{
var localizer = _getActiveLocalizer();
return localizer == null ? base[key] : localizer[key];
}
}
IHtmlLocalizer _getActiveLocalizer()
{
return _httpContextAccessor.HttpContext.Features.Get<IActiveViewLocalizerFeature>()?.ViewLocalizer;
}
}
public static class HtmlLocalizerFactoryWithRuntimeResourcesPathExtensions
{
public static T WithResourcesPath<T>(this T factory, string resourcesPath) where T : IHtmlLocalizerFactory
{
if (factory is IRuntimeResourcesPath overridableFactory)
{
overridableFactory.SetRuntimeResourcesPath(resourcesPath);
}
return factory;
}
}
public interface IActiveViewLocalizerFeature
{
IHtmlLocalizer ViewLocalizer { get; }
}
public class ActiveViewLocalizerFeature : IActiveViewLocalizerFeature
{
public ActiveViewLocalizerFeature(IHtmlLocalizer viewLocalizer)
{
ViewLocalizer = viewLocalizer;
}
public IHtmlLocalizer ViewLocalizer { get; }
}
public interface IRuntimeResourcesPath
{
string ResourcesPath { get; }
void SetRuntimeResourcesPath(string resourcesPath);
}
public class RuntimeResourcesPathHtmlLocalizerFactory : HtmlLocalizerFactory, IRuntimeResourcesPath
{
readonly IStringLocalizerFactory _stringLocalizerFactory;
public RuntimeResourcesPathHtmlLocalizerFactory(IStringLocalizerFactory localizerFactory) : base(localizerFactory)
{
_stringLocalizerFactory = localizerFactory;
}
//NOTE: the factory is registered as a singleton, so we need this to manage different resource paths used on different tasks
readonly AsyncLocal<string> _asyncResourcePath = new AsyncLocal<string>();
public string ResourcesPath => _asyncResourcePath.Value;
void IRuntimeResourcesPath.SetRuntimeResourcesPath(string resourcesPath)
{
_asyncResourcePath.Value = resourcesPath;
}
public override IHtmlLocalizer Create(string baseName, string location)
{
if (_stringLocalizerFactory is IRuntimeResourcesPath overridableFactory)
{
overridableFactory.SetRuntimeResourcesPath(ResourcesPath);
}
return base.Create(baseName, location);
}
}
public static class RuntimeResourcesPathHtmlLocalizerFactoryExtensions
{
/// <summary>
/// Creates an IHtmlLocalizer with a runtime resources path (instead of using the configured ResourcesPath)
/// </summary>
public static IHtmlLocalizer CreateWithResourcesPath(this IHtmlLocalizerFactory factory, string resourcesPath, string baseName, string location = null)
{
location = location ?? Assembly.GetEntryAssembly().GetName().Name;
var result = factory.WithResourcesPath(resourcesPath).Create(baseName, location);
factory.WithResourcesPath(null);
return result;
}
}
public static class RuntimeResourcesPathLocalizationExtensions
{
static IHtmlLocalizer _useLocalizer(ActionContext actionContext, string resourcesPath, string viewPath)
{
var factory = actionContext.HttpContext.RequestServices.GetRequiredService<IHtmlLocalizerFactory>();
viewPath = viewPath.Substring(0, viewPath.Length - Path.GetExtension(viewPath).Length).TrimStart('/', '\\')
.Replace("/", ".").Replace("\\", ".");
var location = Assembly.GetEntryAssembly().GetName().Name;
var localizer = factory.CreateWithResourcesPath(resourcesPath, viewPath, location);
actionContext.HttpContext.Features.Set<IActiveViewLocalizerFeature>(new ActiveViewLocalizerFeature(localizer));
return localizer;
}
/// <summary>
/// Can be used inside Controller
/// </summary>
public static IHtmlLocalizer UseLocalizer(this ActionContext actionContext, string resourcesPath, string viewOrPageName = null)
{
//find the view before getting the path
var razorViewEngine = actionContext.HttpContext.RequestServices.GetRequiredService<IRazorViewEngine>();
if (actionContext is ControllerContext cc)
{
viewOrPageName = viewOrPageName ?? cc.ActionDescriptor.ActionName;
var viewResult = razorViewEngine.FindView(actionContext, viewOrPageName, false);
return _useLocalizer(actionContext, resourcesPath, viewResult.View.Path);
}
var pageResult = razorViewEngine.FindPage(actionContext, viewOrPageName);
//NOTE: here we have pageResult.Page is an IRazorPage but we don't use that to call UseLocalizer
//because that IRazorPage instance has very less info (lacking ViewContext, PageContext ...)
//The only precious info we have here is the Page.Path
return _useLocalizer(actionContext, resourcesPath, pageResult.Page.Path);
}
/// <summary>
/// Can be used inside Razor View or Razor Page
/// </summary>
public static IHtmlLocalizer UseLocalizer(this IRazorPage razorPage, string resourcesPath)
{
var path = razorPage.ViewContext.ExecutingFilePath;
if (string.IsNullOrEmpty(path))
{
path = razorPage.ViewContext.View.Path;
}
if (path == null) return null;
return _useLocalizer(razorPage.ViewContext, resourcesPath, path);
}
/// <summary>
/// Can be used inside PageModel
/// </summary>
public static IHtmlLocalizer UseLocalizer(this PageModel pageModel, string resourcesPath)
{
return pageModel.PageContext.UseLocalizer(resourcesPath, pageModel.RouteData.Values["page"]?.ToString()?.TrimStart('/'));
}
}
The custom ResourceManagerStringLocalizerFactory
I've mentioned at the beginning:
public class RuntimeResourcesPathResourceManagerStringLocalizerFactory
: ResourceManagerStringLocalizerFactory, IRuntimeResourcesPath, IStringLocalizerFactory
{
readonly AsyncLocal<string> _asyncResourcePath = new AsyncLocal<string>();
public string ResourcesPath => _asyncResourcePath.Value;
private readonly ConcurrentDictionary<string, ResourceManagerStringLocalizer> _localizerCache =
new ConcurrentDictionary<string, ResourceManagerStringLocalizer>();
public RuntimeResourcesPathResourceManagerStringLocalizerFactory(IOptions<LocalizationOptions> localizationOptions, ILoggerFactory loggerFactory) : base(localizationOptions, loggerFactory)
{
}
protected override ResourceLocationAttribute GetResourceLocationAttribute(Assembly assembly)
{
//we is where we override the configured ResourcesPath and use the runtime ResourcesPath.
return ResourcesPath == null ? base.GetResourceLocationAttribute(assembly) : new ResourceLocationAttribute(ResourcesPath);
}
public void SetRuntimeResourcesPath(string resourcesPath)
{
_asyncResourcePath.Value = resourcesPath;
}
/// <summary>
/// Almost cloned from the source code of ResourceManagerStringLocalizerFactory
/// We need to re-implement this because the framework code caches the result of Create using a cache key depending on only baseName & location.
/// But here we introduce one more parameter of (runtime) ResourcesPath, so we need to include that in the cache key as well for
/// it to work properly (otherwise each time changing the runtime ResourcesPath, the same cached result will be returned, which is wrong).
/// </summary>
IStringLocalizer IStringLocalizerFactory.Create(string baseName, string location)
{
if (baseName == null)
{
throw new ArgumentNullException(nameof(baseName));
}
if (location == null)
{
throw new ArgumentNullException(nameof(location));
}
return _localizerCache.GetOrAdd($"B={baseName},L={location},R={ResourcesPath}", _ =>
{
var assemblyName = new AssemblyName(location);
var assembly = Assembly.Load(assemblyName);
baseName = GetResourcePrefix(baseName, location);
return CreateResourceManagerStringLocalizer(assembly, baseName);
});
}
}
One more extension class to help register the custom services conveniently:
public static class RuntimeResourcesPathLocalizationServiceCollectionExtensions
{
public static IServiceCollection AddRuntimeResourcesPathForLocalization(this IServiceCollection services)
{
services.AddSingleton<IStringLocalizerFactory, RuntimeResourcesPathResourceManagerStringLocalizerFactory>();
services.AddSingleton<IHtmlLocalizerFactory, RuntimeResourcesPathHtmlLocalizerFactory>();
return services.AddSingleton<IViewLocalizer, ActiveLocalizerAwareViewLocalizer>();
}
}
We implement a custom IViewLocalizer
as well so that it can be seamlessly used in your code. Its job is just to check if there is any active instance of IHtmlLocalizer
shared via the HttpContext
(as a feature called IActiveViewLocalizerFeature
. Each different runtime resources path will create a different IHtmlLocalizer
that will be shared as the active localizer. Usually in one request scope (and in the view context), we usually just need to use one runtime resources path (specified at the very beginning before rendering the view).
To register the custom services:
services.AddRuntimeResourcesPathForLocalization();
To use the localizer with runtime resources path:
public async Task<IActionResult> ShowSomething([FromQuery] bool useDifferentResource)
{
if (useDifferentResource)
{
this.UseLocalizer("resources path of your choice");
}
return View("MyView");
}
NOTE: The UseLocalizer
inside the Controller's or PageModel's scope is not very efficient due to an extra logic to find the view/page (using IRazorViewEngine
as you can see in the code). So if possible, you should move the UseLocalizer
to the RazorPage
or View
instead. The switching condition can be passed via view-model or any other ways (view data, view bag, ...).
Upvotes: 2