关于 localization-globalization
面向 ASP.NET Core Razor Pages 开发者的本地化与全球化模式指南,涵盖资源组织、区域性处理、格式化、语言切换、复数形式和从右到左布局。
主要功能
- 01按功能组织资源文件
- 02区域性检测与回退配置
- 03Razor Pages 视图和模型本地化
- 04按区域性格式化日期、数字和货币
- 05语言切换与选择持久化
- 06复数形式和 RTL 布局支持
适用场景
- 01为 Razor Pages 添加多语言
- 02根据用户区域性格式化数值
- 03实现可记忆选择的语言切换器
- 04支持复数形式和 RTL 语言
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill localization-globalization安装后在 Claude Code 中输入/localization-globalization确认已加载。
详细介绍
原理
全球化应用需要支持多种语言、文化和格式约定。本地化实现不佳会导致维护难题、用户体验不一致,以及日期、数字和货币方面难以发现的错误。这些模式提供了一种可维护的方法,用于构建真正面向全球用户的 Razor Pages 应用。
模式
模式 1:资源文件结构
按功能组织资源,并采用合适的命名约定和文化层级。
/Pages
/Shared
_Layout.cshtml
Resources/
_Layout.resx (default/en)
_Layout.es.resx (Spanish)
_Layout.fr.resx (French)
_Layout.de.resx (German)
/Account
Login.cshtml
Login.cshtml.cs
Resources/
Login.resx
Login.es.resx
Login.fr.resx
/Products
Index.cshtml
Resources/
Index.resx
Index.es.resx
/Resources (Shared resources)
SharedResources.resx
SharedResources.es.resx
ValidationMessages.resx
// SharedResources.cs - Type-safe resource access
public class SharedResources
{
// This class is just a marker for IStringLocalizer<SharedResources>
}
// Strongly-typed resources with code generator
public static class ResourceKeys
{
public const string WelcomeMessage = "WelcomeMessage";
public const string SaveButton = "SaveButton";
public const string CancelButton = "CancelButton";
public const string ErrorOccurred = "ErrorOccurred";
public const string RequiredField = "RequiredField";
}
模式 2:本地化配置
配置请求本地化,并正确检测文化设置和回退选项。
// Program.cs
var supportedCultures = new[]
{
new CultureInfo("en-US"),
new CultureInfo("en-GB"),
new CultureInfo("es-ES"),
new CultureInfo("es-MX"),
new CultureInfo("fr-FR"),
new CultureInfo("de-DE")
};
builder.Services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
});
builder.Services.AddRequestLocalization(options =>
{
options.DefaultRequestCulture = new RequestCulture("en-US");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
// Culture detection order:
// 1. Query string (?culture=es-ES)
// 2. Cookie (.AspNetCore.Culture)
// 3. Accept-Language header
// 4. Default culture
options.RequestCultureProviders = new List<IRequestCultureProvider>
{
new QueryStringRequestCultureProvider(),
new CookieRequestCultureProvider(),
new AcceptLanguageHeaderRequestCultureProvider()
};
});
// Register view localization
builder.Services.AddRazorPages()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResources));
});
// Middleware placement (must be before routing)
var app = builder.Build();
app.UseRequestLocalization();
app.UseRouting();
app.MapRazorPages();
模式 3:Razor Pages 本地化
通过正确注入资源,实现视图和 PageModel 本地化。
// PageModel with localization
public class ProductEditModel : PageModel
{
private readonly IStringLocalizer<ProductEditModel> _localizer;
private readonly IStringLocalizer<SharedResources> _sharedLocalizer;
private readonly ILogger<ProductEditModel> _logger;
public ProductEditModel(
IStringLocalizer<ProductEditModel> localizer,
IStringLocalizer<SharedResources> sharedLocalizer,
ILogger<ProductEditModel> logger)
{
_localizer = localizer;
_sharedLocalizer = sharedLocalizer;
_logger = logger;
}
[BindProperty]
public ProductInput Input { get; set; } = new();
public string PageTitle => _localizer["EditProductTitle"];
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
try
{
await SaveProductAsync(Input);
TempData["SuccessMessage"] = _localizer["ProductSaved"];
return RedirectToPage("/Products/Index");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save product");
ModelState.AddModelError(string.Empty, _sharedLocalizer["ErrorOccurred"]);
return Page();
}
}
}
// View with localization
@page "{id:guid}"
@model ProductEditModel
@inject IStringLocalizer<SharedResources> SharedLocalizer
@inject IViewLocalizer ViewLocalizer
@{
ViewData["Title"] = Model.PageTitle;
}
<h1>@ViewLocalizer["EditProductTitle"]</h1>
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Input.Name">@ViewLocalizer["ProductName"]</label>
<input asp-for="Input.Name" class="form-control"
placeholder="@ViewLocalizer["NamePlaceholder"]" />
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Input.Price">@ViewLocalizer["Price"]</label>
<input asp-for="Input.Price" class="form-control"
type="number" step="0.01" />
<small class="form-text text-muted">
@ViewLocalizer["PriceHelpText"]
</small>
</div>
<button type="submit" class="btn btn-primary">
@SharedLocalizer["SaveButton"]
</button>
<a asp-page="/Products/Index" class="btn btn-secondary">
@SharedLocalizer["CancelButton"]
</a>
</form>
模式 4:特定文化的格式化
使用适应文化设置的格式来显示日期、数字和货币。
// Extension methods for consistent formatting
public static class FormattingExtensions
{
public static string ToCurrency(this decimal amount, IFormatProvider? provider = null)
{
return amount.ToString("C", provider ?? CultureInfo.CurrentCulture);
}
public static string ToShortDate(this DateTime date, IFormatProvider? provider = null)
{
return date.ToString("d", provider ?? CultureInfo.CurrentCulture);
}
public static string ToLongDate(this DateTime date, IFormatProvider? provider = null)
{
return date.ToString("D", provider ?? CultureInfo.CurrentCulture);
}
public static string ToCompactNumber(this int number, IFormatProvider? provider = null)
{
return number.ToString("N0", provider ?? CultureInfo.CurrentCulture);
}
}
// View usage
@inject IStringLocalizer<SharedResources> Localizer
<div class="product-details">
<p>@Localizer["PriceLabel"]: @Model.Product.Price.ToCurrency()</p>
<p>@Localizer["AvailableFrom"]: @Model.Product.AvailableDate.ToLongDate()</p>
<p>@Localizer["StockQuantity"]: @Model.Product.Stock.ToCompactNumber()</p>
</div>
// Culture-specific validation messages
public class ProductInput
{
[Required(ErrorMessageResourceName = "RequiredField",
ErrorMessageResourceType = typeof(SharedResources))]
[StringLength(100, ErrorMessageResourceName = "MaxLength",
ErrorMessageResourceType = typeof(SharedResources))]
public required string Name { get; set; }
[Range(0.01, 999999.99, ErrorMessageResourceName = "InvalidPrice",
ErrorMessageResourceType = typeof(SharedResources))]
[DataType(DataType.Currency)]
public decimal Price { get; set; }
[DataType(DataType.Date)]
[Display(ResourceType = typeof(SharedResources), Name = "AvailableDateLabel")]
public DateTime AvailableDate { get; set; }
}
模式 5:语言切换器实现
创建可持久保存文化设置的语言切换器。
// Culture controller for switching languages
public class CultureController : Controller
{
[HttpPost]
public IActionResult SetCulture(string culture, string returnUrl)
{
if (!string.IsNullOrEmpty(culture))
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
);
}
return LocalRedirect(returnUrl ?? "/");
}
}
// Language switcher partial view (_LanguageSwitcher.cshtml)
@inject IOptions<RequestLocalizationOptions> LocOptions
@{
var requestCulture = Context.Features.Get<IRequestCultureFeature>()!;
var cultureItems = LocOptions.Value.SupportedUICultures!
.Select(c => new SelectListItem {
Value = c.Name,
Text = c.DisplayName,
Selected = c.Name == requestCulture.RequestCulture.UICulture.Name
})
.ToList();
var returnUrl = string.IsNullOrEmpty(Context.Request.Path)
? "~/"
: $"~{Context.Request.Path.Value}{Context.Request.QueryString}";
}
<form asp-controller="Culture" asp-action="SetCulture"
asp-route-returnUrl="@returnUrl" method="post"
class="form-inline">
<select name="culture"
class="form-control form-control-sm"
onchange="this.form.submit();"
asp-for="@requestCulture.RequestCulture.UICulture.Name"
asp-items="cultureItems">
</select>
</form>
// Include in _Layout.cshtml
<div class="navbar-nav">
<partial name="_LanguageSwitcher" />
</div>
模式 6:复数形式和复杂字符串
正确处理复数形式和带参数的字符串。
// Using Humanizer for pluralization
public static class LocalizationHelpers
{
public static string FormatItemsCount(IStringLocalizer localizer, int count)
{
var key = count switch
{
0 => "ItemsCountZero",
1 => "ItemsCountOne",
_ => "ItemsCountMany"
};
return localizer[key, count];
}
public static string FormatTimeRemaining(IStringLocalizer localizer, TimeSpan time)
{
if (time.TotalDays >= 1)
return localizer["DaysRemaining", (int)time.TotalDays];
if (time.TotalHours >= 1)
return localizer["HoursRemaining", (int)time.TotalHours];
if (time.TotalMinutes >= 1)
return localizer["MinutesRemaining", (int)time.TotalMinutes];
return localizer["LessThanMinute"];
}
}
// Resource file entries
// ItemsCountZero: "No items"
// ItemsCountOne: "One item"
// ItemsCountMany: "{0} items"
// DaysRemaining: "{0} days remaining"
// HoursRemaining: "{0} hours remaining"
// MinutesRemaining: "{0} minutes remaining"
// LessThanMinute: "Less than a minute"
// View usage
<p>@LocalizationHelpers.FormatItemsCount(Localizer, Model.Items.Count)</p>
<p>@LocalizationHelpers.FormatTimeRemaining(Localizer, Model.TimeRemaining)</p>
模式 7:RTL(从右到左)支持
通过合适的 CSS 和布局,支持阿拉伯语和希伯来语等 RTL 语言。
// View component for RTL detection
public class RtlLayoutViewComponent : ViewComponent
{
private static readonly string[] RtlCultures = { "ar", "he", "fa", "ur" };
public IViewComponentResult Invoke()
{
var currentCulture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
var isRtl = RtlCultures.Contains(currentCulture);
return View(isRtl);
}
}
// _Layout.cshtml modifications
@inject IViewLocalizer Localizer
@{
var isRtl = CultureInfo.CurrentCulture.TextInfo.IsRightToLeft;
var dir = isRtl ? "rtl" : "ltr";
}
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentCulture.TwoLetterISOLanguageName" dir="@dir">
<head>
@if (isRtl)
{
<link rel="stylesheet" href="~/css/bootstrap.rtl.min.css" />
<link rel="stylesheet" href="~/css/site.rtl.css" />
}
else
{
<link rel="stylesheet" href="~/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />
}
</head>
<body>
</body>
</html>
// CSS with logical properties (works for both LTR and RTL)
.sidebar {
margin-inline-start: 1rem; /* Replaces margin-left */
padding-inline-end: 0.5rem; /* Replaces padding-right */
border-inline-start: 1px solid #ddd;
}
反模式
// ❌ BAD: Hard-coded strings
public string GetMessage() => "Hello, World!";
// ✅ GOOD: Use localizer
public string GetMessage() => _localizer["HelloWorld"];
// ❌ BAD: Concatenating strings for pluralization
var message = $"You have {count} item{(count != 1 ? "s" : "")}";
// ✅ GOOD: Use resource keys for each case
var message = count switch
{
0 => _localizer["NoItems"],
1 => _localizer["OneItem"],
_ => _localizer["ManyItems", count]
};
// ❌ BAD: Formatting without culture
var price = $"${amount:F2}"; // Wrong currency symbol, wrong format
// ✅ GOOD: Culture-aware formatting
var price = amount.ToString("C", CultureInfo.CurrentCulture);
// ❌ BAD: Storing culture in session
HttpContext.Session.SetString("Culture", "es-ES"); // Not thread-safe
// ✅ GOOD: Use built-in cookie or query string providers
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("es-ES")));
// ❌ BAD: Not providing fallback resources
// Only es-ES.resx exists, user requests es-MX
// ✅ GOOD: Provide neutral culture resources
// es.resx (neutral) + es-ES.resx (specific) + es-MX.resx (specific)
// ❌ BAD: Loading all resources on startup
foreach (var culture in supportedCultures)
{
var resources = LoadResourcesForCulture(culture); // Expensive!
}
// ✅ GOOD: Let ASP.NET Core lazy-load resources as needed
// ❌ BAD: Mixing UI culture and data culture
Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE"); // Numbers/dates
Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES"); // Resources
// ✅ GOOD: Usually keep them in sync unless you have specific requirements
var culture = new CultureInfo(userPreferredLanguage);
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
// ❌ BAD: Not validating culture codes
public void SetCulture(string culture)
{
CultureInfo.CurrentCulture = new CultureInfo(culture); // Throws on invalid!
}
// ✅ GOOD: Validate against supported cultures
public bool TrySetCulture(string cultureCode)
{
var culture = supportedCultures.FirstOrDefault(c =>
c.Name.Equals(cultureCode, StringComparison.OrdinalIgnoreCase));
if (culture is null) return false;
CultureInfo.CurrentCulture = culture;
CultureInfo.CurrentUICulture = culture;
return true;
}
参考资料
评论
支持 Markdown