首页 > 解决方案 > ASP.NET Core:以编程方式为本地化视图设置资源文件

问题描述

我有用 ASP.NET Core v2.1 编写的网络应用程序。该应用程序使用通过配置的本地化视图,并且通过注入cshtml 文件LocalizationOptions.ResourcesPath = "Resources"来访问本地化字符串。IViewLocalizer

在某些情况下,我想使用与文件夹中的默认资源文件不同的资源文件来渲染视图Resources。另一个资源文件与默认的具有相同的键(无需更改视图),因此只会呈现不同的文本。

例如,在控制器中考虑这样的操作方法并查看我想要解决的评论:

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

标签: c#asp.netasp.net-mvcasp.net-corelocalization

解决方案


首先,我不太确定您的要求的必要性。但是通过深入研究源代码,我发现这是可能的,并在这里提出了一个解决方案。

实际上是IViewLocalizer被实例化的,IHtmlLocalizerFactory它被注册为单例。这取决于IStringLocalizerFactory哪个也注册为单例。这里我们使用资源文件的本地化(由 管理ResourceManager)所以实现类是ResourceManagerStringLocalizerFactory. 该工厂使用选项LocalizationOptions来获取ResourcesPath用于创建实例的配置,该实例IStringLocalizer由包装HtmlLocalizer并最终包装在ViewLocalizer. 这里的重点是结果由缓存键缓存,具体取决于视图/页面路径和程序集的名称(嵌入资源)。因此,在第一次创建实例之后ViewLocalizer(可通过 DI 获得),它将被缓存,您没有机会更改配置ResourcesPath或拦截以某种方式更改它。

这意味着我们需要一个自定义ResourceManagerStringLocalizerFactory来覆盖该Create方法(实际上它不是虚拟的,但我们可以重新实现它)。我们需要在缓存键中再包含一个因素(运行时资源路径),以便缓存能够正常工作。还有一种虚拟方法ResourceManagerStringLocalizerFactory可以被覆盖以提供您的运行时资源路径:GetResourceLocationAttribute. 为了最小化 custom 的实现代码ResourceManagerStringLocalizerFactory,我选择了覆盖该方法。通过阅读源码可以看出,提供自己的运行时资源路径并不是唯一的拦截点,但似乎是最简单的。

这是核心原则。然而,当涉及到完整解决方案的实施时,事情就没有那么简单了。这是完整的代码:

/// <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('/'));
    }
}

ResourceManagerStringLocalizerFactory我在开头提到的习俗:

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

另一个扩展类来帮助方便地注册自定义服务:

public static class RuntimeResourcesPathLocalizationServiceCollectionExtensions
{
    public static IServiceCollection AddRuntimeResourcesPathForLocalization(this IServiceCollection services)
    {
        services.AddSingleton<IStringLocalizerFactory, RuntimeResourcesPathResourceManagerStringLocalizerFactory>();
        services.AddSingleton<IHtmlLocalizerFactory, RuntimeResourcesPathHtmlLocalizerFactory>();
        return services.AddSingleton<IViewLocalizer, ActiveLocalizerAwareViewLocalizer>();
    }
}

我们还实现了一个自定义IViewLocalizer,以便它可以在您的代码中无缝使用。它的工作只是检查是否有任何活动的 shared via 实例(作为IHtmlLocalizer一个HttpContext名为context),我们通常只需要使用一个运行时资源路径(在渲染视图之前一开始就指定)。IActiveViewLocalizerFeatureIHtmlLocalizer

注册自定义服务:

services.AddRuntimeResourcesPathForLocalization();

要将本地化程序与运行时资源路径一起使用:

public async Task<IActionResult> ShowSomething([FromQuery] bool useDifferentResource)
{
  if (useDifferentResource)
  {
     this.UseLocalizer("resources path of your choice");
  }
    
  return View("MyView");
}

注意UseLocalizer由于需要额外的逻辑来查找视图/页面(IRazorViewEngine如您在代码中看到的那样),Controller 或 PageModel 的范围内部效率不高。因此,如果可能,您应该将UseLocalizer移至RazorPageor View。切换条件可以通过视图模型或任何其他方式(视图数据,视图包,...)传递。


推荐阅读