Search code examples
c#asp.netasp.net-mvcasp.net-corelocalization

ASP.NET Core: programatically set the resource file for localized view


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");
}

Solution

  • 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, ...).