ASP.NET 8 – 单个 Resx 文件构建多语言应用程序
构建多语言 ASP.NET 8 MVC 应用程序的实用指南
1. 需要更新的教程
有许多关于如何为 ASP.NET Core 8 MVC 构建多语言应用程序的教程,但许多都已过时,不适用于旧版本的 .NET,或者在如何解决将所有语言资源 strings 集中到单个文件的问题上含糊不清。因此,计划提供实际操作说明,以及代码示例和概念验证应用程序。
1.1 本系列文章
本系列文章包括:
- ASP.NET 8 – 多语言应用程序(单 Resx 文件)
- ASP.NET 8 – 多语言应用程序(单 Resx 文件)– 第 2 部分 – 替代方法
- ASP.NET 8 – 多语言应用程序(单 Resx 文件)– 第 3 部分 – 表单验证字符串
- ASP.NET 8 – 多语言应用程序(单 Resx 文件)– 第 4 部分 – 资源管理器
2. 多语言站点、国际化和本地化
这里我将不解释拥有多种语言站点的优势,以及本地化和国际化是什么。你可以在互联网的许多地方找到相关信息(参见 [4])。我将专注于如何在 ASP.NET Core 8 MVC 中实际构建这样的站点。如果你不确定 .resx 文件是什么,那么本文可能不适合你。
3. 共享资源方法
默认情况下,ASP.NET Core 8 MVC 技术为每个控制器和视图设想了单独的资源文件 .resx。但大多数人不喜欢这样,因为在应用程序的不同地方,大多数多语言 strings 是相同的,我们希望它们都放在同一个地方。文献 [1] 将这种方法称为“共享资源”方法。为了实现这一点,我们将创建一个标记类 SharedResoureces.cs 来 agrup(分组)所有资源。然后,在我们的应用程序中,我们将调用依赖注入 (DI) 来注入这个特定的类/类型,而不是特定的控制器/视图。这是 Microsoft 文档 [1] 中提到的一点技巧,它在 StackOverflow 文章 [6] 中引起了一些困惑。我们计划在这里将其弄清楚。虽然 [1] 中已解释了所有内容,但所需的是一些实际示例,就像我们在这里提供的一个示例。
4. 多语言应用程序步骤
4.1 配置本地化服务和中间件
本地化服务在 Program.cs 中配置
private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };
    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
}
private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}
4.2 创建标记类 SharedResources.cs
这只是一个用于 agrupir 共享资源的虚拟标记类。我们需要它是因为它的名称和类型。
似乎命名空间需要与应用程序根命名空间相同,而根命名空间又需要与程序集名称相同。我在更改命名空间时遇到了一些问题,它不起作用。如果对你不起作用,你可以尝试在 DI 指令中使用完整的类名,如下所示:
IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
“SharedResource”这个名字没有什么魔法,你可以将其命名为“MyResources”,然后在代码中更改所有对“MyResources”的引用,一切仍然会正常工作。
位置似乎可以是任何文件夹,尽管一些文章 ([6] 声称需要放在项目根文件夹,但在本示例中我没有遇到这样的问题。对我来说,看起来可以是任何文件夹,只要保持你的命名空间整洁即可。
//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */
    public class SharedResource
    {
    }
}
4.3 创建语言资源文件
在“Resources”文件夹中,创建你的语言资源文件,并确保将它们命名为 SharedResources.xx.resx。


4.4 选择语言/区域性
基于 [5],本地化服务有三个默认提供程序
- QueryStringRequestCultureProvider
- CookieRequestCultureProvider
- AcceptLanguageHeaderRequestCultureProvider
由于大多数应用程序通常会提供一种机制来使用 ASP.NET Core 区域性 cookie 来设置区域性,因此在我们的示例中,我们将只关注这种方法。
这是设置 .AspNetCore.Culture cookie 的代码
private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };
    //this code sets .AspNetCore.Culture cookie
    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
    );
}
可以使用 Chrome DevTools 轻松查看 Cookie

我构建了一个小型应用程序来演示它,这是我更改语言的屏幕

请注意,我在页脚添加了一些调试信息,以显示 **请求语言 Cookie** 的值,以查看应用程序是否按预期工作。
4.5 在控制器中使用本地化服务
在控制器中,当然,依赖注入 (DI) 会注入并填充所有依赖项。关键是我们请求的是特定的 type=SharedResource。
如果对你不起作用,你可以尝试在 DI 指令中使用完整的类名,如下所示:
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer.
这是代码片段:
public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly IStringLocalizer<SharedResource> _stringLocalizer;
    private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;
    /* Here is, of course, the Dependency Injection (DI) coming in and filling 
     * all the dependencies. The key thing is we are asking for a specific 
     * type=SharedResource. 
     * If it doesn't work for you, you can try to use full class name
     * in your DI instruction, like this one:
     * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
     */
    public HomeController(ILogger<HomeController> logger, 
        IStringLocalizer<SharedResource> stringLocalizer,
        IHtmlLocalizer<SharedResource> htmlLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }
    
    //================================
    
    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    //so, here we use IStringLocalizer
    model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
    //so, here we use IHtmlLocalizer
    model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
    return View(model);
}
4.6 在视图中使用本地化服务
在视图中,当然,依赖注入 (DI) 会注入并填充所有依赖项。关键是我们请求的是特定的 type=SharedResource。
如果对你不起作用,你可以尝试在 DI 指令中使用完整的类名,如下所示:
IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
这是代码片段:
@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@model LocalizationExampleViewModel
@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@
@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer
@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>
        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>
        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>
        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}
4.7 执行结果
执行结果如下所示

请注意,我在页脚添加了一些调试信息,以显示 **请求语言 Cookie** 的值,以查看应用程序是否按预期工作。
4.8 IHtmlLocalizer<SharedResource> 的问题
我在使用 IHtmlLocalizer<SharedResource> 时遇到了一些问题。 它解析 strings 并进行翻译,这表明设置是正确的。但是,它对 HTML 不起作用,尽管声称可以。我尝试翻译简单的 HTML,如“<b>Wellcome</b>”,但它不起作用。但是,它对简单的字符串,如“Wellcome”,则有效。
5. 完整代码
由于大多数人都喜欢可以直接复制粘贴的代码,所以这里是应用程序的完整代码。
//Program.cs===========================================================================
namespace SharedResources01
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);
            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);
            // Add services to the container.
            builder.Services.AddControllersWithViews();
            //====App===================================================================
            var app = builder.Build();
            //adding multi-language support
            AddingMultiLanguageSupport(app);
            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthorization();
            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");
            app.Run();
        }
        private static void AddingMultiLanguageSupportServices
                                       (WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };
            builder.Services.AddLocalization
                    (options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
        }
        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}
//SharedResource.cs===================================================
namespace SharedResources01
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one:
    * IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder
    * I do not see such problems in this example. 
    * To me looks it can be any folder, just keep your
    * namespace tidy. 
    */
    public class SharedResource
    {
    }
}
//HomeController.cs================================================================
namespace SharedResources01.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly IStringLocalizer<SharedResource> _stringLocalizer;
        private readonly IHtmlLocalizer<SharedResource> _htmlLocalizer;
        /* Here is, of course, the Dependency Injection (DI) coming in and filling 
         * all the dependencies. The key thing is we are asking for a specific 
         * type=SharedResource. 
         * If it doesn't work for you, you can try to use full class name
         * in your DI instruction, like this one:
         * IStringLocalizer<SharedResources01.SharedResource> stringLocalizer
         */
        public HomeController(ILogger<HomeController> logger, 
            IStringLocalizer<SharedResource> stringLocalizer,
            IHtmlLocalizer<SharedResource> htmlLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
            _htmlLocalizer = htmlLocalizer;
        }
        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }
            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }
        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },
                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },
                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },
                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }
        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };
            //this code sets .AspNetCore.Culture cookie
            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
            );
        }
        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            //so, here we use IStringLocalizer
            model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
            //so, here we use IHtmlLocalizer
            model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
            return View(model);
        }
        public IActionResult Error()
        {
            return View(new ErrorViewModel 
            { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}
//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources01.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";
        public bool IsSubmit { get; set; } = false;
        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}
//LocalizationExampleViewModel.cs===============================================
namespace SharedResources01.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? IStringLocalizerInController { get; set; }
        public LocalizedHtmlString? IHtmlLocalizerInController { get; set; }
    }
}
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel
@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>
        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" 
                     class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" 
                            asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}
@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization
@model LocalizationExampleViewModel
@* Here is of course the Dependency Injection (DI) coming in and filling
all the dependencies. The key thing is we are asking for a specific
type=SharedResource. 
If it doesn't work for you, you can try to use full class name
in your DI instruction, like this one:
@inject IStringLocalizer<SharedResources01.SharedResource> StringLocalizer
 *@
@inject IStringLocalizer<SharedResource> StringLocalizer
@inject IHtmlLocalizer<SharedResource> HtmlLocalizer
@{
    <div style="width:600px">
        <p class="bg-info">
            IStringLocalizer Localized  in Controller: 
            @Model.IStringLocalizerInController
        </p>
        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            IStringLocalizer Localized  in View: @text1
        </p>
        <p class="bg-info">
            IHtmlLocalizer Localized  in Controller: 
            @Model.IHtmlLocalizerInController
        </p>
        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            IHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}
6. 参考文献
- [1] 使 ASP.NET Core 应用内容可本地化
- [2] 为本地化 ASP.NET Core 应用提供多语言资源
- [3] 实现一个策略,为本地化 ASP.NET Core 应用中的每个请求选择语言/区域性
- [4] ASP.NET Core 中的全球化和本地化
- [5] 疑难解答 ASP.NET Core 本地化
- [6] 借助 SharedResources 进行 ASP.NET Core 本地化
7. 历史
- 2024 年 3 月 6 日:初始版本



