前言

Asp.Net Core 提供了内置的网站国际化(全球化与本地化)支持,微软还内置了基于 resx 资源字符串的国际化服务组件。可以在入门教程中找到相关内容。

但是内置实现方式有一个明显缺陷,resx 资源是要静态编译到程序集中的,无法在网站运行中临时编辑,灵活性较差。幸好我找到了一个基于数据库资源存储的组件,这个组件完美解决了 resx 资源不灵活的缺陷,经过适当的设置,可以在第一次查找资源时顺便创建数据库记录,而我们要做的就是访问一次相应的网页,让组件创建好记录,然后我们去编辑相应的翻译字段并刷新缓存即可。

但是!又是但是,经过一段时间的使用,发现基于数据库的方式依然存在缺陷,开发中难免有需要删除并重建数据库,初始化环境。这时,之前辛辛苦苦编辑的翻译就会一起灰飞烟灭 (╯‵□′)╯︵┻━┻ 。而 resx 资源却完美避开了这个问题,这时我就在想,能不能让他们同时工作,兼顾灵活性与稳定性,鱼与熊掌兼得。

经过一番摸索,终于得以成功,在此开贴记录分享。

正文

设置并启用国际化服务组件

安装 Nuget 包Localization.SqlLocalizer,这个包依赖 EF Core 进行数据库操作。然后在 Startup 的 ConfigureServices 方法中加入以下代码注册 EF Core 上下文:

services.AddDbContext<LocalizationModelContext>(options =>
    {
        options.UseSqlServer(connectionString);
    },
    ServiceLifetime.Singleton,
    ServiceLifetime.Singleton
);

注册自制的混合国际化服务:

services.AddMixedLocalization(opts =>
    {
        opts.ResourcesPath = "Resources";
    },
    options => options.UseSettings(true, false, true, true)
);

注册请求本地化配置:

services.Configure<RequestLocalizationOptions>(
    options =>
    {
        var cultures =  Configuration.GetSection("Internationalization").GetSection("Cultures")
        .Get<List<string>>()
        .Select(x => new CultureInfo(x)).ToList();
        var supportedCultures = cultures;
 
        var defaultRequestCulture = cultures.FirstOrDefault() ?? new CultureInfo("zh-CN");
        options.DefaultRequestCulture = new RequestCulture(culture: defaultRequestCulture, uiCulture: defaultRequestCulture);
        options.SupportedCultures = supportedCultures;
        options.SupportedUICultures = supportedCultures;
    });

注册 MVC 本地化服务:

services.AddMvc()
    //注册视图本地化服务
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix, opts => { opts.ResourcesPath = "Resources"; })
    //注册数据注解本地化服务
    .AddDataAnnotationsLocalization();

appsettings.json的根对象节点添加属性:

"Internationalization": {
  "Cultures": [
    "zh-CN",
    "en-US"
  ]
}

在某个控制器加入以下动作:

public IActionResult SetLanguage(string lang)
{
    var returnUrl = HttpContext.RequestReferer() ?? "/Home";
 
    Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(lang)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
    );
 
    return Redirect(returnUrl);
}

准备一个页面调用这个动作切换语言。然后,大功告成!

这个自制服务遵循以下规则:优先查找基于 resx 资源的翻译数据,如果找到则直接使用,如果没有找到,再去基于数据库的资源中查找,如果找到则正常使用,如果没有找到则按照对服务的配置决定是否在数据库中生成记录并使用。

自制混合国际化服务组件的实现

本体:

public interface IMiscibleStringLocalizerFactory : IStringLocalizerFactory
{
}
 
public class MiscibleResourceManagerStringLocalizerFactory : ResourceManagerStringLocalizerFactory, IMiscibleStringLocalizerFactory
{
    public MiscibleResourceManagerStringLocalizerFactory(IOptions<LocalizationOptions> localizationOptions, ILoggerFactory loggerFactory) : base(localizationOptions, loggerFactory)
    {
    }
}
 
public class MiscibleSqlStringLocalizerFactory : SqlStringLocalizerFactory, IStringExtendedLocalizerFactory, IMiscibleStringLocalizerFactory
{
    public MiscibleSqlStringLocalizerFactory(LocalizationModelContext context, DevelopmentSetup developmentSetup, IOptions<SqlLocalizationOptions> localizationOptions) : base(context, developmentSetup, localizationOptions)
    {
    }
}
 
public class MixedStringLocalizerFactory : IStringLocalizerFactory
{
    private readonly IEnumerable<IMiscibleStringLocalizerFactory> _localizerFactories;
    private readonly ILogger<MixedStringLocalizerFactory> _logger;
 
    public MixedStringLocalizerFactory(IEnumerable<IMiscibleStringLocalizerFactory> localizerFactories, ILogger<MixedStringLocalizerFactory> logger)
    {
        _localizerFactories = localizerFactories;
        _logger = logger;
    }
 
    public IStringLocalizer Create(string baseName, string location)
    {
        return new MixedStringLocalizer(_localizerFactories.Select(x =>
        {
            try
            {
                return x.Create(baseName, location);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, ex.Message);
                return null;
            }
        }));
    }
 
    public IStringLocalizer Create(Type resourceSource)
    {
        return new MixedStringLocalizer(_localizerFactories.Select(x =>
        {
            try
            {
                return x.Create(resourceSource);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, ex.Message);
                return null;
            }
        }));
    }
}
 
public class MixedStringLocalizer : IStringLocalizer
{
    private readonly IEnumerable<IStringLocalizer> _stringLocalizers;
 
    public MixedStringLocalizer(IEnumerable<IStringLocalizer> stringLocalizers)
    {
        _stringLocalizers = stringLocalizers;
    }
 
    public virtual LocalizedString this[string name]
    {
        get
        {
            var localizer = _stringLocalizers.SingleOrDefault(x => x is ResourceManagerStringLocalizer);
            var result = localizer?[name];
            if (!(result?.ResourceNotFound ?? true)) return result;
 
            localizer = _stringLocalizers.SingleOrDefault(x => x is SqlStringLocalizer) ?? throw new InvalidOperationException($"没有找到可用的 {nameof(IStringLocalizer)}");
            result = localizer[name];
            return result;
        }
    }
 
    public virtual LocalizedString this[string name, params object[] arguments]
    {
        get
        {
            var localizer = _stringLocalizers.SingleOrDefault(x => x is ResourceManagerStringLocalizer);
            var result = localizer?[name, arguments];
            if (!(result?.ResourceNotFound ?? true)) return result;
 
            localizer = _stringLocalizers.SingleOrDefault(x => x is SqlStringLocalizer) ?? throw new InvalidOperationException($"没有找到可用的 {nameof(IStringLocalizer)}");
            result = localizer[name, arguments];
            return result;
        }
    }
 
    public virtual IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        var localizer = _stringLocalizers.SingleOrDefault(x => x is ResourceManagerStringLocalizer);
        var result = localizer?.GetAllStrings(includeParentCultures);
        if (!(result?.Any(x => x.ResourceNotFound) ?? true)) return result;
 
        localizer = _stringLocalizers.SingleOrDefault(x => x is SqlStringLocalizer) ?? throw new InvalidOperationException($"没有找到可用的 {nameof(IStringLocalizer)}");
        result = localizer?.GetAllStrings(includeParentCultures);
        return result;
    }
 
    [Obsolete]
    public virtual IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
 
public class MixedStringLocalizer<T> : MixedStringLocalizer, IStringLocalizer<T>
{
    public MixedStringLocalizer(IEnumerable<IStringLocalizer> stringLocalizers) : base(stringLocalizers)
    {
    }
 
    public override LocalizedString this[string name] => base[name];
 
    public override LocalizedString this[string name, params object[] arguments] => base[name, arguments];
 
    public override IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
    {
        return base.GetAllStrings(includeParentCultures);
    }
 
    [Obsolete]
    public override IStringLocalizer WithCulture(CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}

注册辅助扩展:

public static class MixedLocalizationServiceCollectionExtensions
{
    public static IServiceCollection AddMixedLocalization( 
        this IServiceCollection services,
        Action<LocalizationOptions> setupBuiltInAction = null,
        Action<SqlLocalizationOptions> setupSqlAction = null)
    {
        if (services == null) throw new ArgumentNullException(nameof(services));
 
        services.AddSingleton<IMiscibleStringLocalizerFactory, MiscibleResourceManagerStringLocalizerFactory>();
 
        services.AddSingleton<IMiscibleStringLocalizerFactory, MiscibleSqlStringLocalizerFactory>();
        services.TryAddSingleton<IStringExtendedLocalizerFactory, MiscibleSqlStringLocalizerFactory>();
        services.TryAddSingleton<DevelopmentSetup>();
 
        services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>));
 
        services.AddSingleton<IStringLocalizerFactory, MixedStringLocalizerFactory>();
 
        if (setupBuiltInAction != null) services.Configure(setupBuiltInAction);
        if (setupSqlAction != null) services.Configure(setupSqlAction);
 
        return services;
    }
}

原理简介

服务组件利用了 DI 中可以为同一个服务类型注册多个实现类型的特性,并在构造方法中注入服务集合,便可以将注册的所有实现注入组件同时使用。要注意主控服务和工作服务不能注册为同一个服务类型,不然会导致循环依赖。 内置的国际化框架已经指明了依赖IStringLocalizerFatory,必须将主控服务注册为IStringLocalizerFatory,工作服只能注册为其他类型,不过依然要实现IStringLocalizerFatory,所以最方便的办法就是定义一个新服务类型作为工作服务类型并继承IStringLocalizerFatory

想直接体验效果的可以到文章底部访问我的 Github 下载项目并运行。

结语

这个组件是在计划集成 IdentityServer4 管理面板时发现那个组件使用了 resx 的翻译,而我的现存项目已经使用了数据库翻译存储,两者又不相互兼容的情况下产生的想法。

当时Localization.SqlLocalizer旧版本(2.0.4)还存在无法在视图本地化时正常创建数据库记录的问题,也是我调试修复了 bug 并向原作者提交了拉取请求,原作者也在合并了我的修复后发布了新版本。

这次在集成 IdentityServer4 管理面板时又发现了 bug,正准备联系原作者看怎么处理。

[查看原文](Asp.Net Core 混合全球化与本地化支持 - coredx - 博客园 (cnblogs.com))